demo live stream
This commit is contained in:
@@ -1,11 +1,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var liveWSUpgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(_ *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func registerAPI(se *core.ServeEvent) {
|
func registerAPI(se *core.ServeEvent) {
|
||||||
group := se.Router.Group("/api/cctv")
|
group := se.Router.Group("/api/cctv")
|
||||||
|
|
||||||
@@ -31,11 +42,73 @@ func registerAPI(se *core.ServeEvent) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
group.GET("/live/{streamId}", func(e *core.RequestEvent) error {
|
group.GET("/live/{streamId}", func(e *core.RequestEvent) error {
|
||||||
|
return e.BadRequestError("Use /api/cctv/live/ws/{streamId} for live streaming", nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
group.GET("/live/ws/{streamId}", func(e *core.RequestEvent) error {
|
||||||
streamId := e.Request.PathValue("streamId")
|
streamId := e.Request.PathValue("streamId")
|
||||||
if streamId == "" {
|
if streamId == "" {
|
||||||
return e.BadRequestError("Missing stream ID", nil)
|
return e.BadRequestError("Missing stream ID", nil)
|
||||||
}
|
}
|
||||||
ingestService.SubscribeLive(e.Request.Context(), streamId)
|
|
||||||
|
conn, err := liveWSUpgrader.Upgrade(e.Response, e.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return e.InternalServerError("Failed to upgrade websocket connection", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(e.Request.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stream, err := ingestService.SubscribeLive(ctx, streamId)
|
||||||
|
if err != nil {
|
||||||
|
writeLiveWSError(conn, err.Error())
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clientDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(clientDone)
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-clientDone:
|
||||||
|
return nil
|
||||||
|
case chunk, ok := <-stream:
|
||||||
|
if !ok {
|
||||||
|
writeLiveWSError(conn, "live stream ended")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if chunk == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, err := io.ReadAll(chunk)
|
||||||
|
if err != nil {
|
||||||
|
writeLiveWSError(conn, "failed to read stream chunk")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, payload); err != nil {
|
||||||
|
if errors.Is(err, websocket.ErrCloseSent) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeLiveWSError(conn *websocket.Conn, msg string) {
|
||||||
|
payload := make([]byte, len(msg)+1)
|
||||||
|
payload[0] = 0x03
|
||||||
|
copy(payload[1:], []byte(msg))
|
||||||
|
_ = conn.WriteMessage(websocket.BinaryMessage, payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd
|
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd
|
||||||
github.com/go-gst/go-gst v1.4.0
|
github.com/go-gst/go-gst v1.4.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/gowvp/onvif v0.0.14
|
github.com/gowvp/onvif v0.0.14
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/pocketbase/pocketbase v0.36.7
|
github.com/pocketbase/pocketbase v0.36.7
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v
|
|||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gowvp/onvif v0.0.14 h1:NNrFqzqBHf9Z9MEQOiDpunpagXUHQraRjFmyiXhUwr4=
|
github.com/gowvp/onvif v0.0.14 h1:NNrFqzqBHf9Z9MEQOiDpunpagXUHQraRjFmyiXhUwr4=
|
||||||
github.com/gowvp/onvif v0.0.14/go.mod h1:Dshr55Q/Xgwa9XMQBPBQBMOWj/2Sq+DxLhdNY35uoFc=
|
github.com/gowvp/onvif v0.0.14/go.mod h1:Dshr55Q/Xgwa9XMQBPBQBMOWj/2Sq+DxLhdNY35uoFc=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import (
|
|||||||
"github.com/go-gst/go-gst/gst/app"
|
"github.com/go-gst/go-gst/gst/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
liveFrameTypeInit = 0x01
|
||||||
|
liveFrameTypeMedia = 0x02
|
||||||
|
liveFrameTypeError = 0x03
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
liveSampleTimeout = 200 * time.Millisecond
|
liveSampleTimeout = 200 * time.Millisecond
|
||||||
liveSampleBufferSize = 5
|
liveSampleBufferSize = 5
|
||||||
@@ -97,7 +103,7 @@ func (p *CameraPipeline) addLiveBranch(branchTimeout time.Duration) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create mp4mux element: %w", err)
|
return fmt.Errorf("failed to create mp4mux element: %w", err)
|
||||||
}
|
}
|
||||||
if err := mp4Mux.SetProperty("fragment-duration", uint(100*time.Millisecond.Nanoseconds())); err != nil {
|
if err := mp4Mux.SetProperty("fragment-duration", uint(100)); err != nil {
|
||||||
return fmt.Errorf("failed to set mp4mux fragment-duration property: %w", err)
|
return fmt.Errorf("failed to set mp4mux fragment-duration property: %w", err)
|
||||||
}
|
}
|
||||||
if err := setGlibValueProperty(mp4Mux, "fragment-mode", 0); err != nil { // dash-or-mss
|
if err := setGlibValueProperty(mp4Mux, "fragment-mode", 0); err != nil { // dash-or-mss
|
||||||
@@ -193,6 +199,8 @@ SamplerLoop:
|
|||||||
func (p *CameraPipeline) liveManager(ctx context.Context) {
|
func (p *CameraPipeline) liveManager(ctx context.Context) {
|
||||||
active := false
|
active := false
|
||||||
var initSeg []byte
|
var initSeg []byte
|
||||||
|
assembler := newFMP4Assembler()
|
||||||
|
var lastMediaEmit time.Time
|
||||||
subscribers := &subscribersManager{m: make(map[int]subscriber), nextID: 0}
|
subscribers := &subscribersManager{m: make(map[int]subscriber), nextID: 0}
|
||||||
samples := make(chan []byte, liveSampleBufferSize)
|
samples := make(chan []byte, liveSampleBufferSize)
|
||||||
samplerCtx, samplerCancel := context.WithCancel(ctx)
|
samplerCtx, samplerCancel := context.WithCancel(ctx)
|
||||||
@@ -217,6 +225,9 @@ func (p *CameraPipeline) liveManager(ctx context.Context) {
|
|||||||
case req := <-p.liveSubscribe:
|
case req := <-p.liveSubscribe:
|
||||||
if !active {
|
if !active {
|
||||||
p.log.Info("Activating live branch")
|
p.log.Info("Activating live branch")
|
||||||
|
initSeg = nil
|
||||||
|
assembler = newFMP4Assembler()
|
||||||
|
lastMediaEmit = time.Time{}
|
||||||
if err := p.liveVValve.SetProperty("drop", false); err != nil {
|
if err := p.liveVValve.SetProperty("drop", false); err != nil {
|
||||||
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate video valve: %w", err)}
|
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate video valve: %w", err)}
|
||||||
continue
|
continue
|
||||||
@@ -244,10 +255,29 @@ func (p *CameraPipeline) liveManager(ctx context.Context) {
|
|||||||
case req := <-p.liveUnsubscribe:
|
case req := <-p.liveUnsubscribe:
|
||||||
subscribers.RemoveSubscriber(req.Id)
|
subscribers.RemoveSubscriber(req.Id)
|
||||||
case data := <-samples:
|
case data := <-samples:
|
||||||
// TODO : init segment
|
chunks, err := assembler.Push(data)
|
||||||
p.log.Debug("got video sample")
|
if err != nil {
|
||||||
|
p.log.Warn("Failed to assemble live fMP4 chunks", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
numSamples += 1
|
numSamples += 1
|
||||||
subscribers.Broadcast(data)
|
for _, chunk := range chunks {
|
||||||
|
framed := marshalLiveFrame(chunk.FrameType, chunk.Payload)
|
||||||
|
switch chunk.FrameType {
|
||||||
|
case liveFrameTypeInit:
|
||||||
|
initSeg = framed
|
||||||
|
p.log.Debug("Emitted init segment", "size", len(chunk.Payload))
|
||||||
|
case liveFrameTypeMedia:
|
||||||
|
now := time.Now()
|
||||||
|
if !lastMediaEmit.IsZero() {
|
||||||
|
p.log.Debug("Emitted media segment", "size", len(chunk.Payload), "gap", now.Sub(lastMediaEmit))
|
||||||
|
} else {
|
||||||
|
p.log.Debug("Emitted media segment", "size", len(chunk.Payload))
|
||||||
|
}
|
||||||
|
lastMediaEmit = now
|
||||||
|
}
|
||||||
|
subscribers.Broadcast(framed)
|
||||||
|
}
|
||||||
case <-timeout:
|
case <-timeout:
|
||||||
p.log.Info("Deactivating live branch due to inactivity")
|
p.log.Info("Deactivating live branch due to inactivity")
|
||||||
if err := p.liveVValve.SetProperty("drop", true); err != nil {
|
if err := p.liveVValve.SetProperty("drop", true); err != nil {
|
||||||
@@ -263,6 +293,13 @@ func (p *CameraPipeline) liveManager(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshalLiveFrame(frameType byte, payload []byte) []byte {
|
||||||
|
framed := make([]byte, len(payload)+1)
|
||||||
|
framed[0] = frameType
|
||||||
|
copy(framed[1:], payload)
|
||||||
|
return framed
|
||||||
|
}
|
||||||
|
|
||||||
func (p *CameraPipeline) LiveSubscribe(timeout time.Duration, bufferSize int) (id int, stream <-chan *bytes.Reader, err error) {
|
func (p *CameraPipeline) LiveSubscribe(timeout time.Duration, bufferSize int) (id int, stream <-chan *bytes.Reader, err error) {
|
||||||
result := make(chan subscribeRes)
|
result := make(chan subscribeRes)
|
||||||
req := subscribeReq{
|
req := subscribeReq{
|
||||||
|
|||||||
139
server/ingest/fmp4_assembler.go
Normal file
139
server/ingest/fmp4_assembler.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type liveChunk struct {
|
||||||
|
FrameType byte
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type fmp4Assembler struct {
|
||||||
|
pending []byte
|
||||||
|
|
||||||
|
initReady bool
|
||||||
|
initBuf []byte
|
||||||
|
|
||||||
|
mediaPrefix []byte
|
||||||
|
inFragment bool
|
||||||
|
fragmentBuf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFMP4Assembler() *fmp4Assembler {
|
||||||
|
return &fmp4Assembler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fmp4Assembler) Push(data []byte) ([]liveChunk, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
a.pending = append(a.pending, data...)
|
||||||
|
|
||||||
|
result := make([]liveChunk, 0, 2)
|
||||||
|
for {
|
||||||
|
box, boxType, ok, err := a.nextBox()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunks, err := a.consumeBox(boxType, box)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, chunks...)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fmp4Assembler) nextBox() ([]byte, string, bool, error) {
|
||||||
|
if len(a.pending) < 8 {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headerLen := 8
|
||||||
|
size := uint64(binary.BigEndian.Uint32(a.pending[:4]))
|
||||||
|
if size == 1 {
|
||||||
|
if len(a.pending) < 16 {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
headerLen = 16
|
||||||
|
size = binary.BigEndian.Uint64(a.pending[8:16])
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
// 0 means box runs to stream end; wait for more data.
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
if size < uint64(headerLen) {
|
||||||
|
return nil, "", false, fmt.Errorf("invalid MP4 box size %d", size)
|
||||||
|
}
|
||||||
|
if size > uint64(len(a.pending)) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
boxLen := int(size)
|
||||||
|
boxType := string(a.pending[4:8])
|
||||||
|
box := make([]byte, boxLen)
|
||||||
|
copy(box, a.pending[:boxLen])
|
||||||
|
a.pending = a.pending[boxLen:]
|
||||||
|
return box, boxType, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fmp4Assembler) consumeBox(boxType string, box []byte) ([]liveChunk, error) {
|
||||||
|
if !a.initReady {
|
||||||
|
a.initBuf = append(a.initBuf, box...)
|
||||||
|
if boxType != "moov" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
a.initReady = true
|
||||||
|
initPayload := cloneBytes(a.initBuf)
|
||||||
|
a.initBuf = nil
|
||||||
|
return []liveChunk{{FrameType: liveFrameTypeInit, Payload: initPayload}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch boxType {
|
||||||
|
case "styp", "sidx", "prft":
|
||||||
|
if a.inFragment {
|
||||||
|
a.fragmentBuf = append(a.fragmentBuf, box...)
|
||||||
|
} else {
|
||||||
|
a.mediaPrefix = append(a.mediaPrefix, box...)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
case "moof":
|
||||||
|
a.fragmentBuf = a.fragmentBuf[:0]
|
||||||
|
if len(a.mediaPrefix) > 0 {
|
||||||
|
a.fragmentBuf = append(a.fragmentBuf, a.mediaPrefix...)
|
||||||
|
a.mediaPrefix = a.mediaPrefix[:0]
|
||||||
|
}
|
||||||
|
a.fragmentBuf = append(a.fragmentBuf, box...)
|
||||||
|
a.inFragment = true
|
||||||
|
return nil, nil
|
||||||
|
case "mdat":
|
||||||
|
if !a.inFragment {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
a.fragmentBuf = append(a.fragmentBuf, box...)
|
||||||
|
mediaPayload := cloneBytes(a.fragmentBuf)
|
||||||
|
a.fragmentBuf = a.fragmentBuf[:0]
|
||||||
|
a.inFragment = false
|
||||||
|
return []liveChunk{{FrameType: liveFrameTypeMedia, Payload: mediaPayload}}, nil
|
||||||
|
default:
|
||||||
|
if a.inFragment {
|
||||||
|
a.fragmentBuf = append(a.fragmentBuf, box...)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBytes(data []byte) []byte {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data))
|
||||||
|
copy(out, data)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -154,13 +154,26 @@ func (ingest *Ingest) SubscribeLive(ctx context.Context, streamId string) (<-cha
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to subscribe to live stream %s: %w", streamId, err)
|
return nil, fmt.Errorf("failed to subscribe to live stream %s: %w", streamId, err)
|
||||||
}
|
}
|
||||||
|
out := make(chan *bytes.Reader, 16)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
defer active.pipeline.LiveUnsubscribe(id)
|
defer active.pipeline.LiveUnsubscribe(id)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, nil
|
return
|
||||||
case buf := <-stream:
|
case buf, ok := <-stream:
|
||||||
ingest.log.Debug("Received live stream chunk", "streamId", streamId, "chunkSize", buf.Len())
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case out <- buf:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,9 +197,19 @@ func (p *CameraPipeline) addRtspSource(rtspURL *url.URL) error {
|
|||||||
case "video":
|
case "video":
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case "H264":
|
case "H264":
|
||||||
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph264depay", "h264parse")
|
chain, err = p.addStaticChainProps(
|
||||||
|
pad,
|
||||||
|
p.vTee.GetStaticPad("sink"),
|
||||||
|
elementFactory{name: "rtph264depay"},
|
||||||
|
elementFactory{name: "h264parse", props: map[string]any{"config-interval": int(-1)}},
|
||||||
|
)
|
||||||
case "H265":
|
case "H265":
|
||||||
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph265depay", "h265parse")
|
chain, err = p.addStaticChainProps(
|
||||||
|
pad,
|
||||||
|
p.vTee.GetStaticPad("sink"),
|
||||||
|
elementFactory{name: "rtph265depay"},
|
||||||
|
elementFactory{name: "h265parse", props: map[string]any{"config-interval": int(-1)}},
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
log.Error("Ignoring video pad (unsupported encoding)", "caps", caps.String())
|
log.Error("Ignoring video pad (unsupported encoding)", "caps", caps.String())
|
||||||
return
|
return
|
||||||
|
|||||||
154
web/src/lib/live-mse.ts
Normal file
154
web/src/lib/live-mse.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
export const LIVE_FRAME_INIT = 0x01;
|
||||||
|
export const LIVE_FRAME_MEDIA = 0x02;
|
||||||
|
export const LIVE_FRAME_ERROR = 0x03;
|
||||||
|
|
||||||
|
export type LiveFrame = {
|
||||||
|
type: number;
|
||||||
|
payload: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decodeLiveFrame(data: ArrayBuffer): LiveFrame | null {
|
||||||
|
const bytes = new Uint8Array(data);
|
||||||
|
if (bytes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: bytes[0],
|
||||||
|
payload: bytes.slice(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SourceBufferAppender {
|
||||||
|
private readonly sourceBuffer: SourceBuffer;
|
||||||
|
private readonly onError: (err: unknown) => void;
|
||||||
|
private readonly queue: Uint8Array[] = [];
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(sourceBuffer: SourceBuffer, onError: (err: unknown) => void) {
|
||||||
|
this.sourceBuffer = sourceBuffer;
|
||||||
|
this.onError = onError;
|
||||||
|
this.onUpdateEnd = this.onUpdateEnd.bind(this);
|
||||||
|
this.sourceBuffer.addEventListener("updateend", this.onUpdateEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
append(segment: Uint8Array) {
|
||||||
|
if (this.disposed || segment.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.queue.push(segment);
|
||||||
|
this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingSegments(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.disposed = true;
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.sourceBuffer.removeEventListener("updateend", this.onUpdateEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onUpdateEnd() {
|
||||||
|
this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drain() {
|
||||||
|
if (this.disposed || this.sourceBuffer.updating || this.queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = next.buffer.slice(next.byteOffset, next.byteOffset + next.byteLength) as ArrayBuffer;
|
||||||
|
this.sourceBuffer.appendBuffer(raw);
|
||||||
|
} catch (err) {
|
||||||
|
this.onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickLiveMimeType(initSegment: Uint8Array): string | null {
|
||||||
|
const hasAvc1 = includesASCII(initSegment, "avc1");
|
||||||
|
const hasHvc1 = includesASCII(initSegment, "hvc1");
|
||||||
|
const hasHev1 = includesASCII(initSegment, "hev1");
|
||||||
|
const avcCodec = parseAvcCodec(initSegment);
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
if (hasAvc1 && avcCodec) {
|
||||||
|
candidates.push(`video/mp4; codecs="${avcCodec},mp4a.40.2"`);
|
||||||
|
candidates.push(`video/mp4; codecs="${avcCodec}"`);
|
||||||
|
}
|
||||||
|
if (hasAvc1) {
|
||||||
|
candidates.push('video/mp4; codecs="avc1.640028,mp4a.40.2"');
|
||||||
|
candidates.push('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
|
||||||
|
}
|
||||||
|
if (hasHvc1) {
|
||||||
|
candidates.push('video/mp4; codecs="hvc1,mp4a.40.2"');
|
||||||
|
candidates.push('video/mp4; codecs="hvc1"');
|
||||||
|
}
|
||||||
|
if (hasHev1) {
|
||||||
|
candidates.push('video/mp4; codecs="hev1,mp4a.40.2"');
|
||||||
|
candidates.push('video/mp4; codecs="hev1"');
|
||||||
|
}
|
||||||
|
candidates.push("video/mp4");
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (MediaSource.isTypeSupported(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLiveWsUrl(streamId: string): string {
|
||||||
|
const current = new URL(window.location.href);
|
||||||
|
const apiBaseParam = current.searchParams.get("apiBase");
|
||||||
|
const apiBase = apiBaseParam ? new URL(apiBaseParam, current) : current;
|
||||||
|
const protocol = apiBase.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${apiBase.host}/api/cctv/live/ws/${encodeURIComponent(streamId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAvcCodec(data: Uint8Array): string | null {
|
||||||
|
const idx = indexOfASCII(data, "avcC");
|
||||||
|
if (idx < 0 || idx+8 >= data.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profile = data[idx + 5];
|
||||||
|
const compatibility = data[idx + 6];
|
||||||
|
const level = data[idx + 7];
|
||||||
|
return `avc1.${hex2(profile)}${hex2(compatibility)}${hex2(level)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesASCII(data: Uint8Array, text: string): boolean {
|
||||||
|
return indexOfASCII(data, text) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexOfASCII(data: Uint8Array, text: string): number {
|
||||||
|
if (text.length === 0 || text.length > data.length) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const codes = Array.from(text, (char) => char.charCodeAt(0));
|
||||||
|
for (let i = 0; i <= data.length - codes.length; i++) {
|
||||||
|
let matched = true;
|
||||||
|
for (let j = 0; j < codes.length; j++) {
|
||||||
|
if (data[i + j] !== codes[j]) {
|
||||||
|
matched = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hex2(value: number): string {
|
||||||
|
return value.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
}
|
||||||
392
web/src/routes/cam-poc/+page.svelte
Normal file
392
web/src/routes/cam-poc/+page.svelte
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import {
|
||||||
|
LIVE_FRAME_ERROR,
|
||||||
|
LIVE_FRAME_INIT,
|
||||||
|
LIVE_FRAME_MEDIA,
|
||||||
|
SourceBufferAppender,
|
||||||
|
buildLiveWsUrl,
|
||||||
|
decodeLiveFrame,
|
||||||
|
pickLiveMimeType
|
||||||
|
} from "$lib/live-mse";
|
||||||
|
|
||||||
|
const DEFAULT_STREAM_ID = "w9oheo19vhjmqgs";
|
||||||
|
type Status = "idle" | "connecting" | "streaming" | "closed" | "error";
|
||||||
|
|
||||||
|
let streamId = $state(DEFAULT_STREAM_ID);
|
||||||
|
let wsUrl = $state("");
|
||||||
|
let status = $state<Status>("idle");
|
||||||
|
let wsState = $state("disconnected");
|
||||||
|
let sourceMime = $state("pending");
|
||||||
|
let bufferedRanges = $state("n/a");
|
||||||
|
let errorText = $state("");
|
||||||
|
let playbackHint = $state("");
|
||||||
|
let initFrames = $state(0);
|
||||||
|
let mediaFrames = $state(0);
|
||||||
|
let pendingSegments = $state(0);
|
||||||
|
|
||||||
|
let videoEl: HTMLVideoElement | null = null;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let mediaSource: MediaSource | null = null;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let appender: SourceBufferAppender | null = null;
|
||||||
|
let pendingInit: Uint8Array | null = null;
|
||||||
|
let earlyMedia: Uint8Array[] = [];
|
||||||
|
let monitorTimer: number | null = null;
|
||||||
|
let didInitialSeek = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsUrl = buildLiveWsUrl(streamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const current = new URL(window.location.href);
|
||||||
|
const streamParam = current.searchParams.get("streamId");
|
||||||
|
if (streamParam && streamParam.length > 0) {
|
||||||
|
streamId = streamParam;
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
monitorTimer = window.setInterval(() => {
|
||||||
|
bufferedRanges = formatBufferedRanges(videoEl);
|
||||||
|
ensurePlaybackPosition();
|
||||||
|
}, 500);
|
||||||
|
return () => {
|
||||||
|
if (monitorTimer !== null) {
|
||||||
|
window.clearInterval(monitorTimer);
|
||||||
|
monitorTimer = null;
|
||||||
|
}
|
||||||
|
stop("idle");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (streamId.trim().length === 0) {
|
||||||
|
errorText = "Stream ID is required";
|
||||||
|
status = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stop("idle");
|
||||||
|
status = "connecting";
|
||||||
|
wsState = "connecting";
|
||||||
|
errorText = "";
|
||||||
|
playbackHint = "";
|
||||||
|
sourceMime = "pending";
|
||||||
|
initFrames = 0;
|
||||||
|
mediaFrames = 0;
|
||||||
|
pendingSegments = 0;
|
||||||
|
didInitialSeek = false;
|
||||||
|
earlyMedia = [];
|
||||||
|
pendingInit = null;
|
||||||
|
|
||||||
|
mediaSource = new MediaSource();
|
||||||
|
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||||
|
objectUrl = URL.createObjectURL(mediaSource);
|
||||||
|
if (videoEl) {
|
||||||
|
videoEl.src = objectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws = new WebSocket(buildLiveWsUrl(streamId));
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
ws.onopen = () => {
|
||||||
|
wsState = "open";
|
||||||
|
status = "streaming";
|
||||||
|
void tryStartPlayback();
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
errorText = "WebSocket error";
|
||||||
|
wsState = "error";
|
||||||
|
status = "error";
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (status !== "error") {
|
||||||
|
status = "closed";
|
||||||
|
}
|
||||||
|
wsState = "closed";
|
||||||
|
};
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
const payload = await toArrayBuffer(event.data);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = decodeLiveFrame(payload);
|
||||||
|
if (!frame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleFrame(frame.type, frame.payload);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(nextStatus: Status) {
|
||||||
|
if (ws) {
|
||||||
|
ws.onopen = null;
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.onerror = null;
|
||||||
|
ws.onmessage = null;
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
if (appender) {
|
||||||
|
appender.dispose();
|
||||||
|
appender = null;
|
||||||
|
}
|
||||||
|
pendingInit = null;
|
||||||
|
earlyMedia = [];
|
||||||
|
pendingSegments = 0;
|
||||||
|
didInitialSeek = false;
|
||||||
|
if (mediaSource?.readyState === "open") {
|
||||||
|
try {
|
||||||
|
mediaSource.endOfStream();
|
||||||
|
} catch {
|
||||||
|
// ignore end-of-stream errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaSource = null;
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
}
|
||||||
|
if (videoEl) {
|
||||||
|
videoEl.pause();
|
||||||
|
videoEl.muted = false;
|
||||||
|
videoEl.currentTime = 0;
|
||||||
|
videoEl.removeAttribute("src");
|
||||||
|
videoEl.load();
|
||||||
|
}
|
||||||
|
status = nextStatus;
|
||||||
|
wsState = nextStatus === "idle" ? "disconnected" : wsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSourceOpen() {
|
||||||
|
if (pendingInit) {
|
||||||
|
initSourceBuffer(pendingInit);
|
||||||
|
pendingInit = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFrame(frameType: number, payload: Uint8Array) {
|
||||||
|
if (frameType === LIVE_FRAME_ERROR) {
|
||||||
|
errorText = new TextDecoder().decode(payload) || "Live stream error";
|
||||||
|
status = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frameType === LIVE_FRAME_INIT) {
|
||||||
|
initFrames += 1;
|
||||||
|
if (!mediaSource || mediaSource.readyState !== "open") {
|
||||||
|
pendingInit = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initSourceBuffer(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frameType === LIVE_FRAME_MEDIA) {
|
||||||
|
mediaFrames += 1;
|
||||||
|
if (appender) {
|
||||||
|
appender.append(payload);
|
||||||
|
pendingSegments = appender.pendingSegments();
|
||||||
|
if (mediaFrames <= 3) {
|
||||||
|
void tryStartPlayback();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
earlyMedia.push(payload);
|
||||||
|
pendingSegments = earlyMedia.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSourceBuffer(initSegment: Uint8Array) {
|
||||||
|
if (!mediaSource || mediaSource.readyState !== "open") {
|
||||||
|
pendingInit = initSegment;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mime = pickLiveMimeType(initSegment);
|
||||||
|
if (!mime) {
|
||||||
|
errorText = "No supported MP4 MIME type found for this stream";
|
||||||
|
status = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceMime = mime;
|
||||||
|
let sourceBuffer: SourceBuffer;
|
||||||
|
try {
|
||||||
|
sourceBuffer = mediaSource.addSourceBuffer(mime);
|
||||||
|
} catch (err) {
|
||||||
|
errorText = `Failed to create SourceBuffer (${String(err)})`;
|
||||||
|
status = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appender = new SourceBufferAppender(sourceBuffer, (err) => {
|
||||||
|
errorText = `appendBuffer failed: ${String(err)}`;
|
||||||
|
status = "error";
|
||||||
|
});
|
||||||
|
appender.append(initSegment);
|
||||||
|
for (const segment of earlyMedia) {
|
||||||
|
appender.append(segment);
|
||||||
|
}
|
||||||
|
earlyMedia = [];
|
||||||
|
pendingSegments = appender.pendingSegments();
|
||||||
|
void tryStartPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryStartPlayback() {
|
||||||
|
if (!videoEl || status === "error" || !videoEl.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playbackHint = "";
|
||||||
|
try {
|
||||||
|
videoEl.muted = false;
|
||||||
|
await videoEl.play();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Some browsers block autoplay with audio; muted autoplay is usually allowed.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
videoEl.muted = true;
|
||||||
|
await videoEl.play();
|
||||||
|
playbackHint = "Playback auto-started muted by browser policy. Click Unmute for audio.";
|
||||||
|
ensurePlaybackPosition();
|
||||||
|
} catch {
|
||||||
|
playbackHint = "Playback is waiting for user interaction. Click the video Play button.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unmutePlayback() {
|
||||||
|
if (!videoEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
videoEl.muted = false;
|
||||||
|
try {
|
||||||
|
await videoEl.play();
|
||||||
|
playbackHint = "";
|
||||||
|
} catch {
|
||||||
|
playbackHint = "Browser blocked unmute without interaction. Use the video controls to continue.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePlaybackPosition() {
|
||||||
|
if (!videoEl || videoEl.buffered.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstStart = videoEl.buffered.start(0);
|
||||||
|
const lastEnd = videoEl.buffered.end(videoEl.buffered.length - 1);
|
||||||
|
const liveTarget = Math.max(firstStart, lastEnd - 0.35);
|
||||||
|
const current = videoEl.currentTime;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!didInitialSeek) {
|
||||||
|
videoEl.currentTime = liveTarget;
|
||||||
|
didInitialSeek = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < firstStart-0.2 || current > lastEnd+0.2) {
|
||||||
|
videoEl.currentTime = liveTarget;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore seek failures while metadata is still stabilizing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBufferedRanges(video: HTMLVideoElement | null): string {
|
||||||
|
if (!video || video.buffered.length === 0) {
|
||||||
|
return "empty";
|
||||||
|
}
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < video.buffered.length; i++) {
|
||||||
|
const start = video.buffered.start(i).toFixed(2);
|
||||||
|
const end = video.buffered.end(i).toFixed(2);
|
||||||
|
chunks.push(`${start}-${end}s`);
|
||||||
|
}
|
||||||
|
return chunks.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toArrayBuffer(data: unknown): Promise<ArrayBuffer | null> {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (data instanceof Blob) {
|
||||||
|
return await data.arrayBuffer();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-linear-to-br from-stone-950 via-slate-900 to-zinc-800 px-4 py-6 text-zinc-100 md:px-8">
|
||||||
|
<div class="mx-auto grid w-full max-w-6xl gap-4">
|
||||||
|
<div class="rounded-2xl border border-zinc-100/15 bg-black/20 p-5 backdrop-blur">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight md:text-3xl">Live Camera PoC</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-300">
|
||||||
|
WebSocket binary stream to MSE using muxed fMP4 init/media segments.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||||
|
<Input
|
||||||
|
placeholder="Stream ID"
|
||||||
|
bind:value={streamId}
|
||||||
|
disabled={status === "connecting"}
|
||||||
|
/>
|
||||||
|
<Button onclick={start} disabled={status === "connecting"}>Connect</Button>
|
||||||
|
<Button variant="secondary" onclick={() => stop("idle")}>Stop</Button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 break-all text-xs text-zinc-400">{wsUrl}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-zinc-100/15 bg-black/30">
|
||||||
|
<video bind:this={videoEl} class="h-full min-h-85 w-full bg-black" controls autoplay playsinline></video>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-zinc-100/15 bg-black/20 p-4 text-sm backdrop-blur">
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||||
|
<span>Status</span>
|
||||||
|
<span class="font-medium">{status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||||
|
<span>WebSocket</span>
|
||||||
|
<span class="font-medium">{wsState}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||||
|
<span>SourceBuffer MIME</span>
|
||||||
|
<span class="font-medium">{sourceMime}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||||
|
<span>Init frames</span>
|
||||||
|
<span class="font-medium">{initFrames}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||||
|
<span>Media frames</span>
|
||||||
|
<span class="font-medium">{mediaFrames}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||||
|
<span>Pending append queue</span>
|
||||||
|
<span class="font-medium">{pendingSegments}</span>
|
||||||
|
</div>
|
||||||
|
<div class="py-2">
|
||||||
|
<div>Buffered ranges</div>
|
||||||
|
<div class="mt-1 break-all text-xs text-zinc-300">{bufferedRanges}</div>
|
||||||
|
</div>
|
||||||
|
{#if errorText.length > 0}
|
||||||
|
<div class="mt-3 rounded-lg border border-rose-400/40 bg-rose-900/25 px-3 py-2 text-xs text-rose-100">
|
||||||
|
{errorText}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if playbackHint.length > 0}
|
||||||
|
<div class="mt-3 rounded-lg border border-amber-300/40 bg-amber-900/20 px-3 py-2 text-xs text-amber-100">
|
||||||
|
<p>{playbackHint}</p>
|
||||||
|
<Button class="mt-2" size="sm" variant="secondary" onclick={unmutePlayback}>Unmute</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user