diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9eb6ba --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/termstream.log diff --git a/domain/types.go b/domain/types.go new file mode 100644 index 0000000..ffb7a11 --- /dev/null +++ b/domain/types.go @@ -0,0 +1,7 @@ +package domain + +// AppState holds application state. +type AppState struct { + ContainerRunning bool + IngressLive bool +} diff --git a/go.mod b/go.mod index 2e2873d..933d3d5 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.23.5 require ( github.com/docker/docker v27.5.0+incompatible + github.com/gdamore/tcell/v2 v2.7.1 github.com/google/uuid v1.6.0 + github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/stretchr/testify v1.10.0 ) @@ -16,9 +18,12 @@ require ( github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gdamore/encoding v1.0.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect @@ -26,6 +31,7 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect @@ -34,6 +40,8 @@ require ( go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.17.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.9.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/go.sum b/go.sum index c2e010a..ac4767b 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,10 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= +github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -38,6 +42,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -53,6 +61,12 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE= +github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= @@ -64,6 +78,7 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= @@ -85,26 +100,48 @@ go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= @@ -113,6 +150,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 23f433b..ea8170f 100644 --- a/main.go +++ b/main.go @@ -5,10 +5,11 @@ import ( "fmt" "log/slog" "os" - "os/signal" "git.netflux.io/rob/termstream/container" + "git.netflux.io/rob/termstream/domain" "git.netflux.io/rob/termstream/mediaserver" + "git.netflux.io/rob/termstream/terminal" ) func main() { @@ -21,10 +22,13 @@ func main() { } func run(ctx context.Context) error { - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + state := new(domain.AppState) - ch := make(chan os.Signal, 1) - signal.Notify(ch, os.Interrupt) + logFile, err := os.OpenFile("termstream.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("error opening log file: %w", err) + } + logger := slog.New(slog.NewTextHandler(logFile, nil)) runner, err := container.NewRunner(logger.With("component", "runner")) if err != nil { @@ -39,15 +43,27 @@ func run(ctx context.Context) error { if err != nil { return fmt.Errorf("start media server: %w", err) } + state.ContainerRunning = true + + ui, err := terminal.StartActor(ctx, terminal.StartActorParams{Logger: logger.With("component", "ui")}) + if err != nil { + return fmt.Errorf("start tui: %w", err) + } + defer ui.Close() + + updateUI := func() { ui.SetState(*state) } + updateUI() for { select { - case <-ch: - logger.Info("Received interrupt signal, shutting down...") + case <-ui.C(): + logger.Info("UI closed") return nil - case state, ok := <-srv.C(): + case serverState, ok := <-srv.C(): if ok { - logger.Info("Received state change", "state", state) + state.ContainerRunning = serverState.ContainerRunning + state.IngressLive = serverState.IngressLive + updateUI() } else { logger.Info("State channel closed, shutting down...") return nil diff --git a/mediaserver/actor.go b/mediaserver/actor.go index 9f14e7d..3644072 100644 --- a/mediaserver/actor.go +++ b/mediaserver/actor.go @@ -17,7 +17,8 @@ const imageNameMediaMTX = "bluenviron/mediamtx" // State contains the current state of the media server. type State struct { - Live bool + ContainerRunning bool + IngressLive bool } // action is an action to be performed by the actor. @@ -26,13 +27,11 @@ type action func() // Actor is responsible for managing the media server. type Actor struct { ch chan action + state *State stateChan chan State runner *container.Runner logger *slog.Logger httpClient *http.Client - - // mutable state - live bool } // StartActorParams contains the parameters for starting a new media server @@ -55,6 +54,7 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { actor := &Actor{ ch: make(chan action, chanSize), + state: new(State), stateChan: make(chan State, chanSize), runner: params.Runner, logger: params.Logger, @@ -79,6 +79,7 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { if err != nil { return nil, fmt.Errorf("run container: %w", err) } + actor.state.ContainerRunning = true go actor.actorLoop(containerDone) @@ -90,13 +91,12 @@ func (s *Actor) C() <-chan State { return s.stateChan } +// State returns the current state of the media server. func (s *Actor) State() State { resultChan := make(chan State) - s.ch <- func() { - resultChan <- State{Live: s.live} + resultChan <- *s.state } - return <-resultChan } @@ -115,20 +115,33 @@ func (s *Actor) actorLoop(containerDone <-chan struct{}) { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() + var closing bool + sendState := func() { + if !closing { + s.stateChan <- *s.state + } + } + for { select { case <-containerDone: - s.stateChan <- State{Live: false} + ticker.Stop() + + s.state.ContainerRunning = false + s.state.IngressLive = false + sendState() + + closing = true close(s.stateChan) case <-ticker.C: - live, err := s.checkState() + ingressLive, err := s.fetchIngressStateFromServer() if err != nil { s.logger.Error("Error fetching server state", "error", err) continue } - if live != s.live { - s.live = live - s.stateChan <- State{Live: live} + if ingressLive != s.state.IngressLive { + s.state.IngressLive = ingressLive + sendState() } case action, ok := <-s.ch: if !ok { @@ -153,7 +166,7 @@ type rtmpConnsResponse struct { RemoteAddr string `json:"remoteAddr"` } -func (s *Actor) checkState() (bool, error) { +func (s *Actor) fetchIngressStateFromServer() (bool, error) { req, err := http.NewRequest(http.MethodGet, "http://localhost:9997/v3/rtmpconns/list", nil) if err != nil { return false, fmt.Errorf("new request: %w", err) diff --git a/mediaserver/actor_test.go b/mediaserver/actor_test.go index 477f65d..18774f3 100644 --- a/mediaserver/actor_test.go +++ b/mediaserver/actor_test.go @@ -47,10 +47,11 @@ func TestMediaServerStartStop(t *testing.T) { "container not in RUNNING state", ) + require.False(t, actor.State().IngressLive) launchFFMPEG(t, "rtmp://localhost:1935/live") require.Eventually( t, - func() bool { return actor.State().Live }, + func() bool { return actor.State().IngressLive }, 5*time.Second, 250*time.Millisecond, "actor not in LIVE state", diff --git a/terminal/actor.go b/terminal/actor.go new file mode 100644 index 0000000..b5f08c4 --- /dev/null +++ b/terminal/actor.go @@ -0,0 +1,151 @@ +package terminal + +import ( + "cmp" + "context" + "log/slog" + "strings" + + "git.netflux.io/rob/termstream/domain" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const defaultChanSize = 64 + +type action func() + +// Actor is responsible for managing the terminal user interface. +type Actor struct { + app *tview.Application + ch chan action + doneCh chan struct{} + logger *slog.Logger + serverBox *tview.TextView +} + +// StartActorParams contains the parameters for starting a new terminal user +// interface. +type StartActorParams struct { + ChanSize int + Logger *slog.Logger +} + +// StartActor starts the terminal user interface actor. +func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { + chanSize := cmp.Or(params.ChanSize, defaultChanSize) + + app := tview.NewApplication() + serverBox := tview.NewTextView() + serverBox.SetDynamicColors(true) + serverBox.SetBorder(true) + serverBox.SetTitle("media server") + serverBox.SetTextAlign(tview.AlignCenter) + + destBox := tview.NewBox(). + SetBorder(true). + SetTitle("destinations") + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(serverBox, 7, 0, false). + AddItem(destBox, 0, 1, false) + + container := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(nil, 0, 1, false). + AddItem(flex, 120, 0, false). + AddItem(nil, 0, 1, false) + + app.SetRoot(container, true) + app.EnableMouse(true) + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyCtrlC { + app.Stop() + return nil + } + + return event + }) + + actor := &Actor{ + ch: make(chan action, chanSize), + doneCh: make(chan struct{}, 1), + logger: params.Logger, + app: app, + serverBox: serverBox, + } + + go actor.actorLoop(ctx) + + return actor, nil +} + +// C returns a channel that is closed when the terminal user interface closes. +func (a *Actor) C() <-chan struct{} { + return a.doneCh +} + +func (a *Actor) actorLoop(ctx context.Context) { + uiDone := make(chan struct{}) + go func() { + defer close(uiDone) + + if err := a.app.Run(); err != nil { + a.logger.Error("tui application error", "err", err) + } + }() + + for { + select { + case <-ctx.Done(): + a.logger.Info("Context done") + case <-uiDone: + a.doneCh <- struct{}{} + case action, ok := <-a.ch: + if !ok { + return + } + action() + } + } +} + +// SetState sets the state of the terminal user interface. +func (a *Actor) SetState(state domain.AppState) { + a.ch <- func() { + a.redrawFromState(state) + } +} + +func (a *Actor) redrawFromState(state domain.AppState) { + a.serverBox.SetText(generateServerStatus(state)) + a.app.Draw() +} + +func generateServerStatus(state domain.AppState) string { + var s strings.Builder + + s.WriteString("\n") + s.WriteString("Container status: ") + if state.ContainerRunning { + s.WriteString("[green]running[white]") + } else { + s.WriteString("[red]stopped[white]") + } + s.WriteString("\n\n") + s.WriteString("Ingress stream: ") + if state.IngressLive { + s.WriteString("[green]on-air[white]") + } else { + s.WriteString("[yellow]off-air[white]") + } + s.WriteString("\n\n\n") + + return s.String() +} + +// Close closes the terminal user interface. +func (a *Actor) Close() { + a.app.Stop() +}