package ui import ( "context" "fmt" "image" "image/color" "log" "os" "strconv" "strings" "sync" "time" "gioui.org/app" "gioui.org/font/gofont" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "silverpose/v2/internal/alert" "silverpose/v2/internal/config" "silverpose/v2/internal/fall" "silverpose/v2/internal/monitor" "silverpose/v2/internal/source" ) const ( galleryColumns = 2 galleryRows = 3 galleryCapacity = galleryColumns * galleryRows // 2 columns x 3 rows ) // Light Windows theme tokens (see docs/ui/silver-pose-ui-ux-spec.md). var ( colorBg = color.NRGBA{R: 0xEA, G: 0xF1, B: 0xF8, A: 0xFF} colorSurface = color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} colorText = color.NRGBA{R: 0x1E, G: 0x29, B: 0x3B, A: 0xFF} colorSubtle = color.NRGBA{R: 0x52, G: 0x65, B: 0x7C, A: 0xFF} colorAccent = color.NRGBA{R: 0x25, G: 0x63, B: 0xEB, A: 0xFF} colorAlert = color.NRGBA{R: 0xC6, G: 0x28, B: 0x28, A: 0xFF} colorStroke = color.NRGBA{R: 0xC8, G: 0xD4, B: 0xE3, A: 0xFF} colorScrim = color.NRGBA{A: 0xB0} ) type Settings struct { SourceEnvironment string ModelSHA256 string RuntimeSummary string EventSummary string FFprobePath string ConfigPath string Camera CameraFields Params ParamFields } // CameraFields pre-fill the editable camera connection form. Credentials live // only in the untracked local config; the form saves them back there. type CameraFields struct { ID string Host string Port int Channel string Username string Password string Transport string TimeoutSeconds float64 LowLatency bool } // ParamFields pre-fill the editable common detection parameters. type ParamFields struct { KeypointConfidence float64 ConfirmWindowSeconds float64 HorizontalAngleDegrees float64 ModelConfidence float64 RequireRapidDrop bool RequireLowerBody bool } type Commands struct { Start func() Stop func() } // galleryIncoming is a confirmed screenshot queued off the UI thread. The Gio // op is built once on the UI thread when it is promoted into galleryItem. type galleryIncoming struct { caption string image *image.RGBA } type galleryItem struct { caption string op paint.ImageOp click widget.Clickable lastClick time.Time } // Window observes monitor updates. Background goroutines call Present/ // PresentAlert/ReportIssue, which only mutate shared state under a lock and // Invalidate; the Gio event loop reads that state and renders. type Window struct { appWindow *app.Window theme *material.Theme settings Settings commands Commands spec WindowSpec mu sync.Mutex model ViewModel latestImage *image.RGBA imageGen uint64 pending *alert.Record pendingAt time.Time seenAlerts map[string]struct{} incoming []galleryIncoming seenEvents map[string]struct{} running bool configPath string // Touched only by the event-loop goroutine. imageOp paint.ImageOp imageShownGen uint64 tabs []string tabButton []widget.Clickable active int startButton widget.Clickable stopButton widget.Clickable ackButton widget.Clickable gallery []*galleryItem viewing *galleryItem viewerClose widget.Clickable // Settings form (event-loop goroutine only). editHost widget.Editor editPort widget.Editor editChannel widget.Editor editUsername widget.Editor editPassword widget.Editor editTransport widget.Editor editTimeout widget.Editor editKeypoint widget.Editor editConfirm widget.Editor editAngle widget.Editor editModelConf widget.Editor lowLatency widget.Bool requireRapid widget.Bool requireLower widget.Bool saveCamera widget.Clickable saveParams widget.Clickable testConn widget.Clickable testClose widget.Clickable settingsStatus string settingsList widget.List cameraID string ffprobePath string testMessage string showTest bool testing bool } func setEditor(editor *widget.Editor, value string) { editor.SingleLine = true editor.SetText(value) } func NewWindow(settings Settings, commands Commands) (*Window, error) { spec := MonitorWindowSpec() theme := material.NewTheme() theme.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) theme.Palette.Bg = colorBg theme.Palette.Fg = colorText theme.Palette.ContrastBg = colorAccent theme.Palette.ContrastFg = color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} model := NewViewModel() model.SetSourceReady(settings.SourceEnvironment, settings.SourceEnvironment != "") width, height := spec.Width, spec.Height if screenWidth, screenHeight := screenSize(); screenWidth > 0 && screenHeight > 0 { width = screenWidth * 80 / 100 height = screenHeight * 80 / 100 } appWindow := new(app.Window) appWindow.Option( app.Title(spec.Title), app.Size(unit.Dp(width), unit.Dp(height)), app.MinSize(unit.Dp(960), unit.Dp(640)), ) window := &Window{ appWindow: appWindow, theme: theme, settings: settings, commands: commands, spec: spec, model: model, seenAlerts: make(map[string]struct{}), seenEvents: make(map[string]struct{}), tabs: []string{"监控", "设置"}, } window.tabButton = make([]widget.Clickable, len(window.tabs)) window.configPath = settings.ConfigPath camera, params := settings.Camera, settings.Params setEditor(&window.editHost, camera.Host) setEditor(&window.editPort, strconv.Itoa(defaultPort(camera.Port))) setEditor(&window.editChannel, defaultString(camera.Channel, "102")) setEditor(&window.editUsername, camera.Username) setEditor(&window.editPassword, camera.Password) window.editPassword.Mask = '•' setEditor(&window.editTransport, defaultString(camera.Transport, "tcp")) setEditor(&window.editTimeout, formatFloat(defaultFloat(camera.TimeoutSeconds, 5))) window.lowLatency.Value = camera.LowLatency setEditor(&window.editKeypoint, formatFloat(defaultFloat(params.KeypointConfidence, 0.4))) setEditor(&window.editConfirm, formatFloat(defaultFloat(params.ConfirmWindowSeconds, 1.8))) setEditor(&window.editAngle, formatFloat(defaultFloat(params.HorizontalAngleDegrees, 45))) setEditor(&window.editModelConf, formatFloat(defaultFloat(params.ModelConfidence, 0.25))) window.requireRapid.Value = params.RequireRapidDrop window.requireLower.Value = params.RequireLowerBody window.settingsList.Axis = layout.Vertical window.cameraID = defaultString(camera.ID, "ip-camera") window.ffprobePath = settings.FFprobePath return window, nil } func defaultPort(port int) int { if port <= 0 { return 554 } return port } func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } func defaultFloat(value, fallback float64) float64 { if value == 0 { return fallback } return value } func formatFloat(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) } func (window *Window) Run() int { go func() { err := window.loop() window.stop() if err != nil { log.Print(err) } os.Exit(0) }() app.Main() return 0 } func (window *Window) loop() error { var ops op.Ops for { switch event := window.appWindow.Event().(type) { case app.DestroyEvent: return event.Err case app.FrameEvent: gtx := app.NewContext(&ops, event) window.layout(gtx) event.Frame(gtx.Ops) } } } func (window *Window) start() { window.mu.Lock() window.running = true window.mu.Unlock() if window.commands.Start != nil { window.commands.Start() } } func (window *Window) stop() { window.mu.Lock() window.running = false window.mu.Unlock() if window.commands.Stop != nil { window.commands.Stop() } } // Present stores the newest analysed frame and queues any confirmed screenshot. func (window *Window) Present(update monitor.Update) { window.mu.Lock() state := highestState(update.Result) window.model.Apply(state, update.SourceStatus) if update.SourceMessage != "" { window.model.ConnectionText = "视频流:" + update.SourceMessage } if update.Image != nil { window.latestImage = update.Image window.imageGen++ } if update.Image != nil { for _, record := range update.Events { id := record.Event.EventID if id == "" { continue } if _, seen := window.seenEvents[id]; seen { continue } window.seenEvents[id] = struct{}{} window.incoming = append(window.incoming, galleryIncoming{ caption: fmt.Sprintf("%s %s", record.Event.TrackID, time.Now().Format("15:04:05")), image: update.Image, }) } } window.mu.Unlock() window.appWindow.Invalidate() } // PresentAlert queues one confirm popup per event ID and plays the alert sound. func (window *Window) PresentAlert(record alert.Record) { if !record.Written { return } window.mu.Lock() if _, seen := window.seenAlerts[record.Event.EventID]; seen { window.mu.Unlock() return } window.seenAlerts[record.Event.EventID] = struct{}{} queued := record window.pending = &queued window.pendingAt = time.Now() window.mu.Unlock() playAlertSound() window.appWindow.Invalidate() } // ReportIssue surfaces a recoverable startup/worker issue without red fall // language or exposing configuration values. func (window *Window) ReportIssue(message string) { window.mu.Lock() window.model.ConnectionText = "视频流:" + message window.model.Critical = false window.model.StateText = "检测状态:正常" window.running = false window.mu.Unlock() window.appWindow.Invalidate() } func (window *Window) layout(gtx layout.Context) layout.Dimensions { paint.Fill(gtx.Ops, colorBg) window.mu.Lock() model := window.model frame := window.latestImage gen := window.imageGen pending := window.pending pendingAt := window.pendingAt incoming := window.incoming running := window.running showTest := window.showTest testMessage := window.testMessage window.incoming = nil window.mu.Unlock() for i := range incoming { item := &galleryItem{caption: incoming[i].caption, op: paint.NewImageOp(incoming[i].image)} window.gallery = append([]*galleryItem{item}, window.gallery...) } if len(window.gallery) > galleryCapacity { window.gallery = window.gallery[:galleryCapacity] } hasFrame := frame != nil if hasFrame && gen != window.imageShownGen { window.imageOp = paint.NewImageOp(frame) window.imageShownGen = gen } now := time.Now() for _, item := range window.gallery { if item.click.Clicked(gtx) { if !item.lastClick.IsZero() && now.Sub(item.lastClick) < 350*time.Millisecond { window.viewing = item } item.lastClick = now } } if window.viewerClose.Clicked(gtx) { window.viewing = nil } if window.testClose.Clicked(gtx) { window.mu.Lock() window.showTest = false window.mu.Unlock() showTest = false } // Esc closes the full-image viewer. for { event, ok := gtx.Event(key.Filter{Focus: window, Name: key.NameEscape}) if !ok { break } if pressed, ok := event.(key.Event); ok && pressed.State == key.Press { window.viewing = nil } } if window.viewing != nil { gtx.Execute(key.FocusCmd{Tag: window}) } if window.ackButton.Clicked(gtx) { window.mu.Lock() window.pending = nil window.mu.Unlock() pending = nil } // The confirm popup auto-closes 5 seconds after it opens. if pending != nil { if time.Since(pendingAt) >= 5*time.Second { window.mu.Lock() window.pending = nil window.mu.Unlock() pending = nil } else { gtx.Execute(op.InvalidateCmd{At: pendingAt.Add(5 * time.Second)}) } } content := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(window.layoutTabs), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if window.active == 0 { return window.layoutMonitor(gtx, model, hasFrame, running) } return window.layoutSettings(gtx, model) }), ) } children := []layout.StackChild{layout.Expanded(content)} if window.viewing != nil { children = append(children, layout.Expanded(scrim), layout.Expanded(window.layoutViewer)) } if showTest { children = append(children, layout.Expanded(scrim), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return window.layoutTestDialog(gtx, testMessage) }) }), ) } if pending != nil { record := *pending children = append(children, layout.Expanded(scrim), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return window.layoutDialog(gtx, record) }) }), ) } if len(children) == 1 { return content(gtx) } return layout.Stack{}.Layout(gtx, children...) } func scrim(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, colorScrim, clip.Rect{Max: gtx.Constraints.Min}.Op()) return layout.Dimensions{Size: gtx.Constraints.Min} } func (window *Window) layoutTabs(gtx layout.Context) layout.Dimensions { for i := range window.tabButton { if window.tabButton[i].Clicked(gtx) { window.active = i } } children := make([]layout.FlexChild, 0, len(window.tabs)) for i := range window.tabs { i := i children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { button := material.Button(window.theme, &window.tabButton[i], window.tabs[i]) if i != window.active { button.Background = colorSurface button.Color = colorSubtle } return layout.UniformInset(unit.Dp(6)).Layout(gtx, button.Layout) })) } return layout.Flex{}.Layout(gtx, children...) } func (window *Window) layoutMonitor(gtx layout.Context, model ViewModel, hasFrame, running bool) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Flexed(7, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions { if !hasFrame { return layout.Center.Layout(gtx, material.Body1(window.theme, model.ConnectionText).Layout) } return widget.Image{Src: window.imageOp, Fit: widget.Contain, Position: layout.Center}.Layout(gtx) }) }) }), layout.Flexed(3, func(gtx layout.Context) layout.Dimensions { return window.layoutRightPanel(gtx, model, running) }), ) } func (window *Window) layoutRightPanel(gtx layout.Context, model ViewModel, running bool) layout.Dimensions { if !running && window.startButton.Clicked(gtx) { window.start() } if running && window.stopButton.Clicked(gtx) { window.stop() } return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions { return window.statusControlsGroup(gtx, model, running) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return card(gtx, window.galleryGroup) }), ) }) } func (window *Window) statusControlsGroup(gtx layout.Context, model ViewModel, running bool) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Body1(window.theme, model.ConnectionText).Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := material.Body1(window.theme, model.StateText) if model.Critical { label.Color = colorAlert } return label.Layout(gtx) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return stateButton(gtx, window.theme, &window.startButton, "开始监控", !running) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return stateButton(gtx, window.theme, &window.stopButton, "停止监控", running) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Body1(window.theme, model.EventText).Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { note := material.Body2(window.theme, "红色=确认摔倒") note.Color = colorSubtle return note.Layout(gtx) }), ) }), ) } func (window *Window) galleryGroup(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(window.theme, "最近确认截图(双击看原图)").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Flexed(1, window.layoutGallery), ) } // card wraps content in a white rounded surface with a subtle stroke, so the // gray-blue page shows through the gutters between components (per the UI spec). func card(gtx layout.Context, inner layout.Widget) layout.Dimensions { border := widget.Border{Color: colorStroke, Width: unit.Dp(1), CornerRadius: unit.Dp(8)} return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { macro := op.Record(gtx.Ops) dims := layout.UniformInset(unit.Dp(10)).Layout(gtx, inner) content := macro.Stop() defer clip.UniformRRect(image.Rectangle{Max: dims.Size}, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, colorSurface) content.Add(gtx.Ops) return dims }) } func smallButton(gtx layout.Context, theme *material.Theme, click *widget.Clickable, label string) layout.Dimensions { return stateButton(gtx, theme, click, label, true) } // stateButton renders a compact button; when disabled it is greyed and, via // gtx.Disabled(), drops pointer input so the click never registers. func stateButton(gtx layout.Context, theme *material.Theme, click *widget.Clickable, label string, enabled bool) layout.Dimensions { button := material.Button(theme, click, label) button.Inset = layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4), Left: unit.Dp(10), Right: unit.Dp(10)} button.TextSize = unit.Sp(13) if !enabled { button.Background = color.NRGBA{R: 0xCF, G: 0xD8, B: 0xE3, A: 0xFF} button.Color = color.NRGBA{R: 0x94, G: 0xA3, B: 0xB8, A: 0xFF} gtx = gtx.Disabled() } return button.Layout(gtx) } func (window *Window) layoutGallery(gtx layout.Context) layout.Dimensions { rows := make([]layout.FlexChild, 0, galleryRows) for r := 0; r < galleryRows; r++ { r := r rows = append(rows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { cols := make([]layout.FlexChild, 0, galleryColumns) for c := 0; c < galleryColumns; c++ { index := r*galleryColumns + c cols = append(cols, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if index < len(window.gallery) { return window.layoutCell(gtx, window.gallery[index]) } return layout.Dimensions{Size: gtx.Constraints.Min} })) } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, cols...) })) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) } func (window *Window) layoutCell(gtx layout.Context, item *galleryItem) layout.Dimensions { return item.click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(3)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return widget.Image{Src: item.op, Fit: widget.Contain, Position: layout.Center}.Layout(gtx) }) }) } func (window *Window) layoutViewer(gtx layout.Context) layout.Dimensions { item := window.viewing if item == nil { return layout.Dimensions{Size: gtx.Constraints.Min} } return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { caption := material.Body1(window.theme, item.caption) caption.Color = colorSurface return caption.Layout(gtx) }), layout.Flexed(1, layout.Spacer{}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return smallButton(gtx, window.theme, &window.viewerClose, "关闭") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return widget.Image{Src: item.op, Fit: widget.Contain, Position: layout.Center}.Layout(gtx) }) }), ) }) } func (window *Window) layoutDialog(gtx layout.Context, record alert.Record) layout.Dimensions { width := gtx.Dp(420) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} paint.FillShape(gtx.Ops, colorSurface, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops)) return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { title := material.H6(window.theme, "确认摔倒") title.Color = colorAlert return title.Layout(gtx) }), layout.Rigid(material.Body1(window.theme, "人员:"+record.Event.TrackID).Layout), layout.Rigid(material.Body1(window.theme, fmt.Sprintf("延迟:%.2f 秒", record.Event.LatencySeconds)).Layout), layout.Rigid(material.Body2(window.theme, "截图:"+record.ScreenshotPath).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Button(window.theme, &window.ackButton, "我已知晓").Layout), ) }) }), ) } func (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout.Dimensions { if window.saveCamera.Clicked(gtx) { window.onSaveCamera() } if window.saveParams.Clicked(gtx) { window.onSaveParams() } if window.testConn.Clicked(gtx) { window.onTestConnection() } cameraCard := verticalRows( window.heading("摄像头连接(账号密码仅存本地配置,下次启动生效)"), window.editorRow("IP / 主机", &window.editHost), window.editorRow("端口", &window.editPort), window.editorRow("通道(101 主 / 102 子)", &window.editChannel), window.editorRow("账号", &window.editUsername), window.editorRow("密码", &window.editPassword), window.editorRow("传输 (tcp/udp)", &window.editTransport), window.editorRow("连接超时 (秒)", &window.editTimeout), window.checkRow("低延迟", &window.lowLatency), window.cameraButtonsRow(), ) paramsCard := verticalRows( window.heading("常用检测参数(下次启动生效)"), window.editorRow("关键点置信度", &window.editKeypoint), window.editorRow("确认窗口 (秒, 1-3)", &window.editConfirm), window.editorRow("水平角度阈值 (°)", &window.editAngle), window.editorRow("模型置信度", &window.editModelConf), window.checkRow("要求先快速下移", &window.requireRapid), window.checkRow("要求膝踝清晰", &window.requireLower), window.buttonRow(&window.saveParams, "保存常用检测参数"), window.labelRow(window.settingsStatus), ) depsCard := verticalRows( window.heading("运行依赖(只读)"), window.labelRow("模型 SHA-256:"+shortHash(window.settings.ModelSHA256)), window.labelRow("运行依赖:"+window.settings.RuntimeSummary), ) sections := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { return card(gtx, cameraCard) }, func(gtx layout.Context) layout.Dimensions { return card(gtx, paramsCard) }, func(gtx layout.Context) layout.Dimensions { return card(gtx, depsCard) }, } return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return material.List(window.theme, &window.settingsList).Layout(gtx, len(sections), func(gtx layout.Context, index int) layout.Dimensions { return layout.UniformInset(unit.Dp(6)).Layout(gtx, sections[index]) }) }) } // verticalRows stacks row widgets with a small gap between them. func verticalRows(rows ...layout.Widget) layout.Widget { return func(gtx layout.Context) layout.Dimensions { children := make([]layout.FlexChild, 0, len(rows)*2) for i := range rows { if i > 0 { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } children = append(children, layout.Rigid(rows[i])) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } } func (window *Window) heading(text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return material.Body1(window.theme, text).Layout(gtx) } } func (window *Window) labelRow(text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return material.Body2(window.theme, text).Layout(gtx) } } func (window *Window) spacerRow() layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) } } func (window *Window) editorRow(label string, editor *widget.Editor) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.X = gtx.Dp(150) return material.Body2(window.theme, label).Layout(gtx) }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { border := widget.Border{Color: colorStroke, Width: unit.Dp(1), CornerRadius: unit.Dp(4)} return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(6)).Layout(gtx, material.Editor(window.theme, editor, "").Layout) }) }), ) } } func (window *Window) checkRow(label string, value *widget.Bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return material.CheckBox(window.theme, value, label).Layout(gtx) } } func (window *Window) buttonRow(click *widget.Clickable, label string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return smallButton(gtx, window.theme, click, label) } } func (window *Window) onSaveCamera() { if window.configPath == "" { window.settingsStatus = "未指定本地配置路径,无法保存" return } port, err := strconv.Atoi(strings.TrimSpace(window.editPort.Text())) if err != nil || port < 1 || port > 65535 { window.settingsStatus = "端口无效" return } timeout, err := strconv.ParseFloat(strings.TrimSpace(window.editTimeout.Text()), 64) if err != nil { window.settingsStatus = "连接超时无效" return } transport := strings.ToLower(strings.TrimSpace(window.editTransport.Text())) if transport != "tcp" && transport != "udp" { window.settingsStatus = "传输须为 tcp 或 udp" return } if err := config.WriteLocalCameraSource(window.configPath, window.cameraID, strings.TrimSpace(window.editHost.Text()), port, strings.TrimSpace(window.editChannel.Text()), strings.TrimSpace(window.editUsername.Text()), window.editPassword.Text(), transport, timeout, window.lowLatency.Value); err != nil { window.settingsStatus = "保存失败:" + err.Error() return } window.settingsStatus = "摄像头已保存,将在下次开始监控时生效" } func (window *Window) onSaveParams() { if window.configPath == "" { window.settingsStatus = "未指定本地配置路径,无法保存" return } keypoint, keypointErr := strconv.ParseFloat(strings.TrimSpace(window.editKeypoint.Text()), 64) confirm, confirmErr := strconv.ParseFloat(strings.TrimSpace(window.editConfirm.Text()), 64) angle, angleErr := strconv.ParseFloat(strings.TrimSpace(window.editAngle.Text()), 64) modelConfidence, modelErr := strconv.ParseFloat(strings.TrimSpace(window.editModelConf.Text()), 64) if keypointErr != nil || confirmErr != nil || angleErr != nil || modelErr != nil { window.settingsStatus = "参数须为数字" return } if err := config.WriteLocalEventTuning(window.configPath, keypoint, confirm, angle, modelConfidence, window.requireRapid.Value, window.requireLower.Value); err != nil { window.settingsStatus = "保存失败:" + err.Error() return } window.settingsStatus = "参数已保存,将在下次开始监控时生效" } func (window *Window) cameraButtonsRow() layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return smallButton(gtx, window.theme, &window.saveCamera, "保存摄像头参数") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return smallButton(gtx, window.theme, &window.testConn, "测试连接摄像头") }), ) } } func (window *Window) onTestConnection() { window.mu.Lock() if window.testing { window.mu.Unlock() return } window.mu.Unlock() host := strings.TrimSpace(window.editHost.Text()) username := strings.TrimSpace(window.editUsername.Text()) password := window.editPassword.Text() channel := strings.TrimSpace(window.editChannel.Text()) transport := strings.ToLower(strings.TrimSpace(window.editTransport.Text())) port, portErr := strconv.Atoi(strings.TrimSpace(window.editPort.Text())) timeout, _ := strconv.ParseFloat(strings.TrimSpace(window.editTimeout.Text()), 64) if host == "" || username == "" || password == "" { window.setTestResult("请先填写主机、账号和密码", false) return } if portErr != nil || port < 1 || port > 65535 { window.setTestResult("端口无效", false) return } if timeout <= 0 { timeout = 5 } window.setTestResult("正在测试连接…", true) go func() { streamURL := config.BuildRTSPURL(host, port, username, password, channel) ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() metadata, err := source.Probe(ctx, source.Config{ SourceURL: streamURL, FFprobePath: window.ffprobePath, Transport: transport, Timeout: time.Duration(timeout * float64(time.Second)), }) message := fmt.Sprintf("连接成功:%dx%d @ %.1f fps", metadata.Width, metadata.Height, metadata.FPS) if err != nil { message = "连接失败:" + source.Redact(err.Error(), streamURL) } window.setTestResult(message, false) }() } func (window *Window) setTestResult(message string, testing bool) { window.mu.Lock() window.testMessage = message window.showTest = true window.testing = testing window.mu.Unlock() window.appWindow.Invalidate() } func (window *Window) layoutTestDialog(gtx layout.Context, message string) layout.Dimensions { width := gtx.Dp(440) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} paint.FillShape(gtx.Ops, colorSurface, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops)) return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H6(window.theme, "测试连接摄像头").Layout), layout.Rigid(material.Body1(window.theme, message).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return smallButton(gtx, window.theme, &window.testClose, "关闭") }), ) }) }), ) } func highestState(result fall.FrameResult) fall.State { state := fall.Normal for _, person := range result.People { switch person.State { case fall.Confirmed: return fall.Confirmed case fall.Suspect: if state == fall.Normal || state == fall.Recovering { state = fall.Suspect } case fall.Recovering: if state == fall.Normal { state = fall.Recovering } } } return state } func shortHash(value string) string { if len(value) <= 12 { return value } return strings.ToLower(value[:12]) + "…" }