From 708d156d9688fafa59d5e93351a59ccb54231b9c Mon Sep 17 00:00:00 2001 From: Dmitrii Okunev Date: Sun, 7 Jul 2024 21:43:01 +0100 Subject: [PATCH] Initial commit, pt. 39 --- Makefile | 3 + cmd/streamcli/commands/commands.go | 66 +++++++++++- .../qstreampanel/qml/QStream.qmlproject.qtds | 2 +- pkg/screenshot/screenshot.go | 13 ++- pkg/streamd/server/grpc.go | 29 +++++ pkg/streampanel/consts/keys.go | 1 + pkg/streampanel/{screenshoter.go => image.go} | 31 +++++- pkg/streampanel/monitor.go | 90 ++++++++++++++++ pkg/streampanel/panel.go | 100 +++++++++++++++--- 9 files changed, 312 insertions(+), 23 deletions(-) rename pkg/streampanel/{screenshoter.go => image.go} (74%) create mode 100644 pkg/streampanel/monitor.go diff --git a/Makefile b/Makefile index ee50c3d..fe88164 100644 --- a/Makefile +++ b/Makefile @@ -25,5 +25,8 @@ streampanel-windows: builddir streamd-linux-amd64: builddir CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o build/streamd-linux-amd64 ./cmd/streamd +streamcli-linux-amd64: builddir + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o build/streamcli-linux-amd64 ./cmd/streamcli + builddir: mkdir -p build diff --git a/cmd/streamcli/commands/commands.go b/cmd/streamcli/commands/commands.go index 68f742f..0d34f82 100644 --- a/cmd/streamcli/commands/commands.go +++ b/cmd/streamcli/commands/commands.go @@ -1,9 +1,11 @@ package commands import ( + "bytes" "context" "encoding/json" "fmt" + "io" "os" "github.com/facebookincubator/go-belt/tool/logger" @@ -13,6 +15,7 @@ import ( twitch "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch/types" youtube "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube/types" "github.com/xaionaro-go/streamctl/pkg/streamd/client" + "github.com/xaionaro-go/streamctl/pkg/streampanel/consts" ) var ( @@ -33,24 +36,49 @@ var ( }, } + Stream = &cobra.Command{ + Use: "stream", + } + StreamSetup = &cobra.Command{ - Use: "stream-setup", + Use: "setup", Args: cobra.ExactArgs(0), Run: streamSetup, } StreamStatus = &cobra.Command{ - Use: "stream-status", + Use: "status", Args: cobra.ExactArgs(0), Run: streamStatus, } + Variables = &cobra.Command{ + Use: "variables", + } + + VariablesGet = &cobra.Command{ + Use: "get", + Args: cobra.ExactArgs(1), + Run: variablesGet, + } + + VariablesSet = &cobra.Command{ + Use: "set", + Args: cobra.ExactArgs(1), + Run: variablesSet, + } + LoggerLevel = logger.LevelWarning ) func init() { - Root.AddCommand(StreamSetup) - Root.AddCommand(StreamStatus) + Root.AddCommand(Stream) + Stream.AddCommand(StreamSetup) + Stream.AddCommand(StreamStatus) + + Root.AddCommand(Variables) + Variables.AddCommand(VariablesGet) + Variables.AddCommand(VariablesSet) Root.PersistentFlags().Var(&LoggerLevel, "log-level", "") Root.PersistentFlags().String("remote-addr", "localhost:3594", "the path to the config file") @@ -132,3 +160,33 @@ func streamStatus(cmd *cobra.Command, args []string) { fmt.Printf("%10s: %s\n", platID, statusJSON) } } + +func variablesGet(cmd *cobra.Command, args []string) { + variableKey := args[0] + ctx := cmd.Context() + + remoteAddr, err := cmd.Flags().GetString("remote-addr") + assertNoError(ctx, err) + streamD := client.New(remoteAddr) + + b, err := streamD.GetVariable(ctx, consts.VarKey(variableKey)) + assertNoError(ctx, err) + + _, err = io.Copy(os.Stdout, bytes.NewReader(b)) + assertNoError(ctx, err) +} + +func variablesSet(cmd *cobra.Command, args []string) { + variableKey := args[0] + ctx := cmd.Context() + + remoteAddr, err := cmd.Flags().GetString("remote-addr") + assertNoError(ctx, err) + streamD := client.New(remoteAddr) + + value, err := io.ReadAll(os.Stdin) + assertNoError(ctx, err) + + err = streamD.SetVariable(ctx, consts.VarKey(variableKey), value) + assertNoError(ctx, err) +} diff --git a/pkg/experimental/qstreampanel/qml/QStream.qmlproject.qtds b/pkg/experimental/qstreampanel/qml/QStream.qmlproject.qtds index 71ff839..8affc73 100644 --- a/pkg/experimental/qstreampanel/qml/QStream.qmlproject.qtds +++ b/pkg/experimental/qstreampanel/qml/QStream.qmlproject.qtds @@ -1,6 +1,6 @@ - + EnvironmentId diff --git a/pkg/screenshot/screenshot.go b/pkg/screenshot/screenshot.go index 97b2340..7fc090e 100644 --- a/pkg/screenshot/screenshot.go +++ b/pkg/screenshot/screenshot.go @@ -18,9 +18,16 @@ func Screenshot(cfg Config) (*image.RGBA, error) { return nil, fmt.Errorf("unable to screenshot screen %d: %w", cfg.DisplayID, err) } - rgbaCropped, ok := rgbaFull.SubImage(cfg.Bounds).(*image.RGBA) - if !ok { - return nil, fmt.Errorf("got type %T, but expected %T", rgbaCropped, (*image.RGBA)(nil)) + var rgbaCropped *image.RGBA + resizeTo := cfg.Bounds + if resizeTo.Max.X > 0 && resizeTo.Max.Y > 0 { + var ok bool + rgbaCropped, ok = rgbaFull.SubImage(resizeTo).(*image.RGBA) + if !ok { + return nil, fmt.Errorf("got type %T, but expected %T", rgbaCropped, (*image.RGBA)(nil)) + } + } else { + rgbaCropped = rgbaFull } return rgbaCropped, nil diff --git a/pkg/streamd/server/grpc.go b/pkg/streamd/server/grpc.go index d711c9f..bb66492 100644 --- a/pkg/streamd/server/grpc.go +++ b/pkg/streamd/server/grpc.go @@ -22,6 +22,7 @@ import ( "github.com/xaionaro-go/streamctl/pkg/streamd/api" "github.com/xaionaro-go/streamctl/pkg/streamd/config" "github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc" + "github.com/xaionaro-go/streamctl/pkg/streampanel/consts" ) type GRPCServer struct { @@ -474,3 +475,31 @@ func (grpc *GRPCServer) SubscribeToOAuthRequests( func (grpc *GRPCServer) OpenOAuthURL(authURL string) { grpc.OAuthURLBroadcaster.Submit(authURL) } + +func (grpc *GRPCServer) GetVariable( + ctx context.Context, + req *streamd_grpc.GetVariableRequest, +) (*streamd_grpc.GetVariableReply, error) { + key := consts.VarKey(req.GetKey()) + b, err := grpc.StreamD.GetVariable(ctx, key) + if err != nil { + return nil, fmt.Errorf("unable to get variable '%s': %w", key, err) + } + + return &streamd_grpc.GetVariableReply{ + Key: string(key), + Value: b, + }, nil +} +func (grpc *GRPCServer) SetVariable( + ctx context.Context, + req *streamd_grpc.SetVariableRequest, +) (*streamd_grpc.SetVariableReply, error) { + key := consts.VarKey(req.GetKey()) + err := grpc.StreamD.SetVariable(ctx, key, req.GetValue()) + if err != nil { + return nil, fmt.Errorf("unable to set variable '%s': %w", key, err) + } + + return &streamd_grpc.SetVariableReply{}, nil +} diff --git a/pkg/streampanel/consts/keys.go b/pkg/streampanel/consts/keys.go index a421384..6a9703a 100644 --- a/pkg/streampanel/consts/keys.go +++ b/pkg/streampanel/consts/keys.go @@ -10,6 +10,7 @@ type ImageID string const ( ImageScreenshot = ImageID("screenshot") + ImageChat = ImageID("chat") ) type VarKey string diff --git a/pkg/streampanel/screenshoter.go b/pkg/streampanel/image.go similarity index 74% rename from pkg/streampanel/screenshoter.go rename to pkg/streampanel/image.go index 1fe3a0e..032319f 100644 --- a/pkg/streampanel/screenshoter.go +++ b/pkg/streampanel/image.go @@ -5,10 +5,13 @@ import ( "context" "fmt" "image" + "image/png" "math" + "net/http" "time" "github.com/chai2010/webp" + "github.com/facebookincubator/go-belt/tool/logger" "github.com/nfnt/resize" "github.com/xaionaro-go/streamctl/pkg/screenshot" "github.com/xaionaro-go/streamctl/pkg/screenshoter" @@ -57,7 +60,18 @@ func (p *Panel) getImage( return nil, fmt.Errorf("unable to get a screenshot: %w", err) } - img, err := webp.Decode(bytes.NewReader(b)) + mimeType := http.DetectContentType(b) + + var img image.Image + err = nil + switch mimeType { + case "image/png": + img, err = png.Decode(bytes.NewReader(b)) + case "image/webp": + img, err = webp.Decode(bytes.NewReader(b)) + default: + return nil, fmt.Errorf("unexpected image type %s", mimeType) + } if err != nil { return nil, fmt.Errorf("unable to decode the screenshot: %w", err) } @@ -75,6 +89,17 @@ func (p *Panel) setScreenshot( screenshot image.Image, ) { bounds := screenshot.Bounds() + logger.Tracef(ctx, "screenshot bounds: %#+v", bounds) + if bounds.Max.X == 0 || bounds.Max.Y == 0 { + p.DisplayError(fmt.Errorf("received an empty screenshot")) + p.screenshoterLocker.Lock() + if p.screenshoterClose != nil { + p.screenshoterClose() + } + p.screenshoterLocker.Unlock() + return + } + if bounds.Max.X > ScreenshotMaxWidth || bounds.Max.Y > ScreenshotMaxHeight { factor := 1.0 factor = math.Min(factor, float64(ScreenshotMaxWidth)/float64(bounds.Max.X)) @@ -82,12 +107,16 @@ func (p *Panel) setScreenshot( newWidth := uint(float64(bounds.Max.X) * factor) newHeight := uint(float64(bounds.Max.Y) * factor) screenshot = resize.Resize(newWidth, newHeight, screenshot, resize.Lanczos3) + logger.Tracef(ctx, "rescaled the screenshot from %#+v to %#+v", bounds, screenshot.Bounds()) } p.setImage(ctx, consts.VarKeyImage(consts.ImageScreenshot), screenshot) } func (p *Panel) reinitScreenshoter(ctx context.Context) { + logger.Debugf(ctx, "reinitScreenshoter") + defer logger.Debugf(ctx, "/reinitScreenshoter") + p.screenshoterLocker.Lock() defer p.screenshoterLocker.Unlock() if p.screenshoterClose != nil { diff --git a/pkg/streampanel/monitor.go b/pkg/streampanel/monitor.go new file mode 100644 index 0000000..59e2bbf --- /dev/null +++ b/pkg/streampanel/monitor.go @@ -0,0 +1,90 @@ +package streampanel + +import ( + "context" + "time" + + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/layout" + "github.com/facebookincubator/go-belt/tool/logger" + "github.com/xaionaro-go/streamctl/pkg/streampanel/consts" +) + +func (p *Panel) startMonitorPage( + ctx context.Context, +) { + logger.Debugf(ctx, "startMonitorPage") + defer logger.Debugf(ctx, "/startMonitorPage") + + p.monitorPageUpdaterLocker.Lock() + defer p.monitorPageUpdaterLocker.Unlock() + ctx, cancelFn := context.WithCancel(ctx) + p.monitorPageUpdaterCancel = cancelFn + go func(ctx context.Context) { + p.updateMonitorPage(ctx) + + t := time.NewTicker(time.Second) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + + p.updateMonitorPage(ctx) + } + }(ctx) +} + +func (p *Panel) stopMonitorPage( + ctx context.Context, +) { + logger.Debugf(ctx, "stopMonitorPage") + defer logger.Debugf(ctx, "/stopMonitorPage") + + p.monitorPageUpdaterLocker.Lock() + defer p.monitorPageUpdaterLocker.Unlock() + + if p.monitorPageUpdaterCancel == nil { + return + } + + p.monitorPageUpdaterCancel() + p.monitorPageUpdaterCancel = nil +} + +func (p *Panel) updateMonitorPage( + ctx context.Context, +) { + logger.Tracef(ctx, "updateMonitorPage") + defer logger.Tracef(ctx, "/updateMonitorPage") + + { + img, err := p.getImage(ctx, consts.VarKeyImage(consts.ImageScreenshot)) + if err != nil { + logger.Error(ctx, err) + } else { + imgFyne := canvas.NewImageFromImage(img) + imgFyne.FillMode = canvas.ImageFillOriginal + + p.screenshotContainer.Layout = layout.NewBorderLayout(imgFyne, nil, nil, nil) + p.screenshotContainer.Objects = p.screenshotContainer.Objects[:0] + p.screenshotContainer.Objects = append(p.screenshotContainer.Objects, imgFyne) + p.screenshotContainer.Refresh() + } + } + + { + img, err := p.getImage(ctx, consts.VarKeyImage(consts.ImageChat)) + if err != nil { + logger.Error(ctx, err) + } else { + imgFyne := canvas.NewImageFromImage(img) + imgFyne.FillMode = canvas.ImageFillContain + + p.chatContainer.RemoveAll() + p.chatContainer.Add(imgFyne) + p.chatContainer.Refresh() + } + } +} diff --git a/pkg/streampanel/panel.go b/pkg/streampanel/panel.go index f4c5970..cbccef2 100644 --- a/pkg/streampanel/panel.go +++ b/pkg/streampanel/panel.go @@ -5,6 +5,7 @@ import ( "context" _ "embed" "fmt" + "image" "net/url" "os" "os/exec" @@ -19,8 +20,10 @@ import ( "fyne.io/fyne/v2" fyneapp "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/facebookincubator/go-belt" @@ -72,6 +75,11 @@ type Panel struct { streamTitleField *widget.Entry streamDescriptionField *widget.Entry + monitorPageUpdaterLocker sync.Mutex + monitorPageUpdaterCancel context.CancelFunc + screenshotContainer *fyne.Container + chatContainer *fyne.Container + filterValue string youtubeCheck *widget.Check @@ -89,6 +97,13 @@ type Panel struct { waitWindow fyne.Window } +type Page string + +const ( + PageControl = Page("Control") + PageMonitor = Page("Monitor") +) + func New( configPath string, opts ...Option, @@ -835,12 +850,19 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error { afterStopStreamCommandEntry := widget.NewEntry() afterStopStreamCommandEntry.SetText(cmdAfterStopStream) + oldScreenshoterEnabled := p.Config.Screenshot.Enabled != nil && *p.Config.Screenshot.Enabled + cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { w.Close() }) saveButton := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() { if err := p.SaveConfig(ctx); err != nil { p.DisplayError(fmt.Errorf("unable to save the local config: %w", err)) + } else { + newScreenshotEnabled := p.Config.Screenshot.Enabled != nil && *p.Config.Screenshot.Enabled + if oldScreenshoterEnabled != newScreenshotEnabled { + p.reinitScreenshoter(ctx) + } } obsCfg := cfg.Backends[obs.ID] @@ -1125,7 +1147,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) { obsIsEnabled, err := p.StreamD.IsBackendEnabled(ctx, obs.ID) if err != nil { - logger.Errorf(ctx, "unable to check if OBS is enabled: %w", err) + logger.Error(ctx, fmt.Errorf("unable to check if OBS is enabled: %w", err)) return } if !obsIsEnabled { @@ -1142,7 +1164,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) { obsStreamStatus, err := p.StreamD.GetStreamStatus(ctx, obs.ID) if err != nil { - logger.Errorf(ctx, "unable to get stream status from OBS: %w", err) + logger.Error(ctx, fmt.Errorf("unable to get stream status from OBS: %w", err)) return } logger.Tracef(ctx, "obsStreamStatus == %#+v", obsStreamStatus) @@ -1173,7 +1195,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) { ytIsEnabled, err := p.StreamD.IsBackendEnabled(ctx, youtube.ID) if err != nil { - logger.Errorf(ctx, "unable to check if YouTube is enabled: %w", err) + logger.Error(ctx, fmt.Errorf("unable to check if YouTube is enabled: %w", err)) return } @@ -1184,7 +1206,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) { ytStreamStatus, err := p.StreamD.GetStreamStatus(ctx, youtube.ID) if err != nil { - logger.Errorf(ctx, "unable to get stream status from YouTube: %w", err) + logger.Error(ctx, fmt.Errorf("unable to get stream status from YouTube: %w", err)) return } logger.Tracef(ctx, "ytStreamStatus == %#+v", ytStreamStatus) @@ -1238,8 +1260,7 @@ func (p *Panel) initMainWindow(ctx context.Context) { p.openMenuWindow(ctx) }) - buttonPanel := container.NewHBox( - menuButton, + profileControl := container.NewHBox( widget.NewSeparator(), widget.NewRichTextWithText("Profile:"), widget.NewButtonWithIcon("", theme.ContentAddIcon(), func() { @@ -1247,16 +1268,16 @@ func (p *Panel) initMainWindow(ctx context.Context) { }), ) + topPanel := container.NewHBox( + menuButton, + profileControl, + ) + for _, button := range selectedProfileButtons { button.Disable() - buttonPanel.Add(button) + profileControl.Add(button) } - topPanel := container.NewVBox( - buttonPanel, - profileFilter, - ) - p.setupStreamButton = widget.NewButtonWithIcon(setupStreamString(), theme.SettingsIcon(), func() { p.onSetupStreamButton(ctx) }) @@ -1328,12 +1349,63 @@ func (p *Panel) initMainWindow(ctx context.Context) { ), ) - w.SetContent(container.NewBorder( - topPanel, + controlPage := container.NewBorder( + profileFilter, bottomPanel, nil, nil, profilesList, + ) + + monitorBackground := image.NewGray(image.Rect(0, 0, 1, 1)) + monitorBackgroundFyne := canvas.NewImageFromImage(monitorBackground) + monitorBackgroundFyne.FillMode = canvas.ImageFillStretch + + p.screenshotContainer = container.NewBorder(nil, nil, nil, nil) + p.chatContainer = container.NewBorder(nil, nil, nil, nil) + monitorPage := container.NewStack( + monitorBackgroundFyne, + p.screenshotContainer, + p.chatContainer, + ) + + setPage := func(page Page) { + logger.Debugf(ctx, "setPage(%s)", page) + defer logger.Debugf(ctx, "/setPage(%s)", page) + + if page != PageMonitor { + p.stopMonitorPage(ctx) + } + + switch page { + case PageControl: + monitorPage.Hide() + profileControl.Show() + controlPage.Show() + case PageMonitor: + controlPage.Hide() + profileControl.Hide() + monitorPage.Show() + p.startMonitorPage(ctx) + } + } + + pageSelector := widget.NewSelect( + []string{"Control", "Monitor"}, + func(page string) { + setPage(Page(page)) + }, + ) + pageSelector.SetSelected(string(PageControl)) + topPanel.Add(layout.NewSpacer()) + topPanel.Add(pageSelector) + + w.SetContent(container.NewBorder( + topPanel, + nil, + nil, + nil, + container.NewStack(controlPage, monitorPage), )) w.Show()