package ui import ( "fmt" "image" "image/color" "log" "os" "strings" "sync" "time" "gioui.org/app" "gioui.org/font/gofont" "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/fall" "silverpose/v2/internal/monitor" ) const galleryCapacity = 9 // 3 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} colorScrim = color.NRGBA{A: 0xB0} ) type Settings struct { SourceEnvironment string ModelSHA256 string RuntimeSummary string EventSummary string } 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 seenAlerts map[string]struct{} incoming []galleryIncoming seenEvents map[string]struct{} // 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 } 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 != "") appWindow := new(app.Window) appWindow.Option( app.Title(spec.Title), app.Size(unit.Dp(spec.Width), unit.Dp(spec.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)) return window, nil } 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() { if window.commands.Start != nil { window.commands.Start() } } func (window *Window) stop() { 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.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.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 incoming := window.incoming 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.ackButton.Clicked(gtx) { window.mu.Lock() window.pending = nil window.mu.Unlock() pending = nil } 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) } 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 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 bool) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Flexed(3, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(8)).Layout(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(2, func(gtx layout.Context) layout.Dimensions { return window.layoutRightPanel(gtx, model) }), ) } func (window *Window) layoutRightPanel(gtx layout.Context, model ViewModel) layout.Dimensions { if window.startButton.Clicked(gtx) { window.start() } if window.stopButton.Clicked(gtx) { window.stop() } return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, // Row 1: connection status and detection status on one line. 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), // Row 2: compact start/stop buttons on one line. 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 smallButton(gtx, window.theme, &window.startButton, "开始监控") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return smallButton(gtx, window.theme, &window.stopButton, "停止监控") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), // Row 3: event text and the red-note on one line. 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) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Body2(window.theme, "最近确认截图(双击看原图)").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), // Remaining area: 3x3 reverse-chronological screenshot gallery. layout.Flexed(1, window.layoutGallery), ) }) } func smallButton(gtx layout.Context, theme *material.Theme, click *widget.Clickable, label string) 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) return button.Layout(gtx) } func (window *Window) layoutGallery(gtx layout.Context) layout.Dimensions { rows := make([]layout.FlexChild, 0, 3) for r := 0; r < 3; r++ { r := r rows = append(rows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { cols := make([]layout.FlexChild, 0, 3) for c := 0; c < 3; c++ { index := r*3 + 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 { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H6(window.theme, "来源与运行依赖(只读,不显示 RTSP 地址或密码)").Layout), layout.Rigid(material.Body1(window.theme, model.SettingsSummary).Layout), layout.Rigid(material.Body1(window.theme, "模型 SHA-256:"+shortHash(window.settings.ModelSHA256)).Layout), layout.Rigid(material.Body1(window.theme, "运行依赖:"+window.settings.RuntimeSummary).Layout), layout.Rigid(material.Body1(window.theme, "事件参数:"+window.settings.EventSummary).Layout), layout.Rigid(material.Body2(window.theme, "参数在配置文件中维护;修改后请停止并重新开始监控。").Layout), ) }) } 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]) + "…" }