From 2ba1056c56435f8d98110eca18dba90dc344de66 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:57:44 +0800
Subject: [PATCH] Polish Windows overlay preview
---
privatevoice.src/app.go | 4
privatevoice.src/scripts/build-windows-preview.sh | 4
CHANGELOG.md | 16 ++++
privatevoice.src/internal/overlay/overlay_windows.go | 211 ++++++++++++++++++++++++++++++++++++++++++----------
CODEGRAPH.md | 1
5 files changed, 191 insertions(+), 45 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4e5fda..2261dad 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## v2.1.39 (2026-07-01)
+
+### Windows 动态指示视觉升级
+
+- **Windows overlay 升级为轻量炫彩胶囊**:保留原生 Win32 overlay 路线,不改热键、录音和识别链路,只提升视觉表现。
+- **胶囊窗口更轻更透**:降低背景不透明度,加入更柔和的边框和内层高光,减轻之前厚重发闷的观感。
+- **柱条动画更接近 macOS 风格**:柱条从 5 根扩展到 11 根,录音/处理中加入更丰富的动态起伏、光谱过渡和发光层。
+- **仍保留 Windows 原生行为**:继续使用现有顶层窗、非激活、拖拽和定位逻辑,避免把视觉升级扩大成平台重构。
+
+### 构建
+
+- build: `20260701.2356`
+- 说明: 本版本为 Windows 动态指示视觉升级后的预览候选包。
+
+---
+
## v2.1.38 (2026-06-29)
### 性能诊断
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index ca9cb6d..3d3b984 100644
--- a/CODEGRAPH.md
+++ b/CODEGRAPH.md
@@ -35,6 +35,7 @@
- Windows preview supports `SenseVoice` only.
- Non-SenseVoice models remain visible in settings but are disabled through `model.IsModelSupportedInCurrentBuild`.
- Windows unsupported reason: `windows_preview_unsupported`.
+- Windows overlay remains native Win32/GDI+, with a larger translucent spectrum-style capsule indicator for recording states.
- Build script: `privatevoice.src/scripts/build-windows-preview.sh`.
- Evidence verifier: `privatevoice.src/scripts/verify-windows-qa-evidence.sh`.
- Runtime package includes:
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 89ea0b6..ce6e70c 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
- appVersion = "2.1.38"
- appBuild = "20260629.2014"
+ appVersion = "2.1.39"
+ appBuild = "20260701.2356"
appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
diff --git a/privatevoice.src/internal/overlay/overlay_windows.go b/privatevoice.src/internal/overlay/overlay_windows.go
index 8bd89f6..b19d38e 100755
--- a/privatevoice.src/internal/overlay/overlay_windows.go
+++ b/privatevoice.src/internal/overlay/overlay_windows.go
@@ -49,10 +49,15 @@
// Colors (ARGB)
const (
- clrCapsuleBG = 0xD91C1C1E
- clrBorder = 0x20FFFFFF
- clrText = 0xFFB4B4B4
+ clrCapsuleBG = 0xA6101318
+ clrCapsuleHi = 0x18FFFFFF
+ clrBorder = 0x2EFFFFFF
+ clrText = 0xFFE7EDF5
+ clrTextMuted = 0xFFB6C3D2
clrBlue = 0xFF007AFF
+ clrCyan = 0xFF2ED3FF
+ clrViolet = 0xFF7A6CFF
+ clrPink = 0xFFFF5EB8
clrGreen = 0xFF34C759
clrRed = 0xFFFF3B30
clrOrange = 0xFFFF9500
@@ -60,15 +65,15 @@
// Dimensions (logical px)
const (
- capW = 170
- capH = 48
- capR = 24
- barW = 5
- barGap = 4
- barR = 2.5
- nBars = 5
- txtSz = 12.0
- txtGap = 12
+ capW = 420
+ capH = 92
+ capR = 46
+ barW = 7
+ barGap = 6
+ barR = 3.5
+ nBars = 11
+ txtSz = 17.0
+ txtGap = 20
)
// ---- Win32 structs ----
@@ -230,13 +235,13 @@
func statusColor(s Status) uint32 {
switch s {
case StatusLoading:
- return clrBlue
+ return clrCyan
case StatusReady, StatusDone:
return clrGreen
- case StatusRecording, StatusError:
+ case StatusRecording, StatusFreetalking:
+ return clrCyan
+ case StatusError:
return clrRed
- case StatusFreetalking:
- return clrBlue
case StatusProcessing, StatusCancelled, StatusNoVoice, StatusNoContent:
return clrOrange
default:
@@ -288,7 +293,7 @@
done: make(chan struct{}),
}
for i := range o.barHeights {
- o.barHeights[i] = 14
+ o.barHeights[i] = 16
}
go o.run()
return o
@@ -472,8 +477,8 @@
text = cmd.text
barClr = statusColor(cmd.status)
case vol := <-o.volCh:
- copy(volumes[0:4], volumes[1:5])
- volumes[4] = vol
+ copy(volumes[0:nBars-1], volumes[1:nBars])
+ volumes[nBars-1] = vol
case pos := <-o.posCh:
o.posX, o.posY = pos[0], pos[1]
uSetWindowPos.Call(hwnd, 0, uintptr(o.posX), uintptr(o.posY), 0, 0,
@@ -532,7 +537,7 @@
o.animTime += 0.033
o.animate(status, volumes)
- o.renderFrame(memDC, hwnd, screenDC, font, strFmt, barClr, text, alpha)
+ o.renderFrame(memDC, hwnd, screenDC, font, strFmt, status, barClr, text, alpha)
}
}
@@ -544,12 +549,14 @@
switch status {
case StatusLoading:
for i := 0; i < nBars; i++ {
- pulse := math.Sin(t*2+float64(i)*0.3)*0.5 + 0.5
- o.barHeights[i] = (10 + pulse*20) * s
+ pulse := math.Sin(t*2.4+float64(i)*0.42)*0.5 + 0.5
+ o.barHeights[i] = (12 + pulse*28) * s
}
case StatusReady, StatusDone:
for i := 0; i < nBars; i++ {
- o.barHeights[i] = o.barHeights[i]*0.85 + 14*s*0.15
+ pulse := math.Sin(t*1.4+float64(i)*0.25)*0.5 + 0.5
+ target := (15 + pulse*7) * s
+ o.barHeights[i] = o.barHeights[i]*0.78 + target*0.22
}
case StatusRecording, StatusFreetalking:
hasReal := false
@@ -562,29 +569,29 @@
for i := 0; i < nBars; i++ {
var target float64
if hasReal {
- target = (8 + volumes[i]*32) * s
+ target = (10 + volumes[i]*44) * s
} else {
- wave := math.Sin(t*4+float64(i)*0.8)*0.5 + 0.5
- rnd := math.Sin(t*7+float64(i)*1.5) * 0.3
- target = (10 + (wave+rnd)*25) * s
+ wave := math.Sin(t*4.6+float64(i)*0.55)*0.5 + 0.5
+ rnd := math.Sin(t*7.5+float64(i)*1.1) * 0.2
+ target = (12 + (wave+rnd)*30) * s
}
- o.barHeights[i] = o.barHeights[i]*0.6 + target*0.4
+ o.barHeights[i] = o.barHeights[i]*0.52 + target*0.48
}
case StatusProcessing:
for i := 0; i < nBars; i++ {
- wave := math.Sin(t*3+float64(i)*0.6)*0.5 + 0.5
- o.barHeights[i] = (12 + wave*18) * s
+ wave := math.Sin(t*3.2+float64(i)*0.5)*0.5 + 0.5
+ o.barHeights[i] = (14 + wave*22) * s
}
default:
for i := 0; i < nBars; i++ {
- o.barHeights[i] = o.barHeights[i]*0.85 + 14*s*0.15
+ o.barHeights[i] = o.barHeights[i]*0.8 + 16*s*0.2
}
}
}
// ---- Rendering ----
-func (o *winOverlay) renderFrame(memDC, hwnd, screenDC, font, strFmt uintptr, barClr uint32, text string, alpha byte) {
+func (o *winOverlay) renderFrame(memDC, hwnd, screenDC, font, strFmt uintptr, status Status, barClr uint32, text string, alpha byte) {
var gfx uintptr
gpFromHDC.Call(memDC, uintptr(unsafe.Pointer(&gfx)))
if gfx == 0 {
@@ -596,11 +603,21 @@
gpSetTextHint.Call(gfx, textHintAntiAlias)
gpClear.Call(gfx, 0) // transparent
- // Capsule background
- fillRoundRect(gfx, 0, 0, float64(o.sW), float64(o.sH), o.sCapR, clrCapsuleBG)
+ capsuleW := float64(o.sW)
+ capsuleH := float64(o.sH)
+ capsuleR := o.sCapR
+ edgeInset := 1.5 * o.scale
+ contentInsetX := 18.0 * o.scale
+ contentInsetY := 14.0 * o.scale
+ contentH := capsuleH - contentInsetY*2
+
+ // Colored halo + lighter translucent body.
+ fillRoundRect(gfx, 0, 0, capsuleW, capsuleH, capsuleR, scaleAlpha(barClr, 0.16))
+ fillRoundRect(gfx, edgeInset, edgeInset, capsuleW-edgeInset*2, capsuleH-edgeInset*2, maxf(capsuleR-edgeInset, 1), clrCapsuleBG)
+ fillRoundRect(gfx, contentInsetX, contentInsetY, capsuleW-contentInsetX*2, contentH*0.58, maxf(contentH*0.29, 8*o.scale), clrCapsuleHi)
// Inner border
- strokeRoundRect(gfx, 0.5, 0.5, float64(o.sW)-1, float64(o.sH)-1, o.sCapR-0.5, clrBorder, 1)
+ strokeRoundRect(gfx, 0.5, 0.5, capsuleW-1, capsuleH-1, maxf(capsuleR-0.5, 1), clrBorder, float32(maxf(1.0*o.scale, 1)))
// Layout: bars + optional text, centered
barsW := float32(float64(nBars)*o.sBarW + float64(nBars-1)*o.sBarGap)
@@ -608,30 +625,66 @@
if text != "" && font != 0 {
textW = measureText(gfx, font, strFmt, text)
}
+ maxTextW := float32(capsuleW-contentInsetX*2) - barsW - float32(o.sTxtGap)
+ if maxTextW < 0 {
+ maxTextW = 0
+ }
+ if textW > maxTextW {
+ textW = maxTextW
+ }
totalW := barsW
if textW > 0 {
totalW += float32(o.sTxtGap) + textW
}
startX := (float32(o.sW) - totalW) / 2
+ // Bar bed glow
+ barBedX := float64(startX) - 12*o.scale
+ barBedW := float64(barsW) + 24*o.scale
+ fillRoundRect(
+ gfx,
+ barBedX,
+ contentInsetY+4*o.scale,
+ barBedW,
+ contentH-8*o.scale,
+ maxf((contentH-8*o.scale)/2, 6*o.scale),
+ scaleAlpha(barClr, 0.10),
+ )
+
// Bars
for i := 0; i < nBars; i++ {
bx := startX + float32(float64(i)*(o.sBarW+o.sBarGap))
bh := float32(o.barHeights[i])
- if bh < float32(6*o.scale) {
- bh = float32(6 * o.scale)
+ if bh < float32(8*o.scale) {
+ bh = float32(8 * o.scale)
}
- if bh > float32(40*o.scale) {
- bh = float32(40 * o.scale)
+ if bh > float32(56*o.scale) {
+ bh = float32(56 * o.scale)
}
by := (float32(o.sH) - bh) / 2
- fillRoundRect(gfx, float64(bx), float64(by), o.sBarW, float64(bh), o.sBarR, barClr)
+ barColor := barFillColor(status, i, o.animTime, barClr)
+ glowH := float64(bh) + 18*o.scale
+ glowY := float64(by) - 9*o.scale
+ fillRoundRect(
+ gfx,
+ float64(bx)-o.sBarW*0.4,
+ glowY,
+ o.sBarW*1.8,
+ glowH,
+ o.sBarR*1.8,
+ scaleAlpha(barColor, 0.18),
+ )
+ fillRoundRect(gfx, float64(bx), float64(by), o.sBarW, float64(bh), o.sBarR, barColor)
}
// Text
if text != "" && font != 0 && strFmt != 0 {
tx := startX + barsW + float32(o.sTxtGap)
- drawText(gfx, font, strFmt, text, tx, 0, textW+4, float32(o.sH), clrText)
+ textColor := uint32(clrText)
+ if status == StatusReady || status == StatusDone {
+ textColor = uint32(clrTextMuted)
+ }
+ drawText(gfx, font, strFmt, text, tx, 0, textW+4, float32(o.sH), textColor)
}
// UpdateLayeredWindow
@@ -642,8 +695,84 @@
memDC, uintptr(unsafe.Pointer(&ptSrc)), 0, uintptr(unsafe.Pointer(&blend)), ulwAlpha)
}
+func barFillColor(status Status, index int, t float64, base uint32) uint32 {
+ progress := 0.0
+ if nBars > 1 {
+ progress = float64(index) / float64(nBars-1)
+ }
+ switch status {
+ case StatusRecording, StatusFreetalking:
+ return mixColor(spectralColor(progress+t*0.08), base, 0.18)
+ case StatusLoading, StatusProcessing:
+ return mixColor(spectralColor(progress+t*0.16), base, 0.32)
+ case StatusReady, StatusDone:
+ return mixColor(spectralColor(progress+t*0.03), clrGreen, 0.70)
+ case StatusError:
+ return mixColor(clrRed, clrPink, progress*0.35)
+ case StatusCancelled, StatusNoVoice, StatusNoContent:
+ return mixColor(clrOrange, clrPink, progress*0.22)
+ default:
+ return base
+ }
+}
+
// ---- GDI+ drawing helpers ----
+func clamp01(v float64) float64 {
+ if v < 0 {
+ return 0
+ }
+ if v > 1 {
+ return 1
+ }
+ return v
+}
+
+func maxf(a, b float64) float64 {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func argb(a, r, g, b byte) uint32 {
+ return uint32(a)<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)
+}
+
+func colorChannels(c uint32) (byte, byte, byte, byte) {
+ return byte(c >> 24), byte(c >> 16), byte(c >> 8), byte(c)
+}
+
+func mixColor(a, b uint32, t float64) uint32 {
+ t = clamp01(t)
+ aa, ar, ag, ab := colorChannels(a)
+ ba, br, bg, bb := colorChannels(b)
+ mix := func(x, y byte) byte {
+ return byte(math.Round(float64(x) + (float64(y)-float64(x))*t))
+ }
+ return argb(mix(aa, ba), mix(ar, br), mix(ag, bg), mix(ab, bb))
+}
+
+func scaleAlpha(c uint32, factor float64) uint32 {
+ factor = clamp01(factor)
+ a, r, g, b := colorChannels(c)
+ return argb(byte(math.Round(float64(a)*factor)), r, g, b)
+}
+
+func spectralColor(progress float64) uint32 {
+ progress = progress - math.Floor(progress)
+ switch {
+ case progress < 0.25:
+ return mixColor(clrCyan, clrBlue, progress/0.25)
+ case progress < 0.55:
+ return mixColor(clrBlue, clrViolet, (progress-0.25)/0.30)
+ case progress < 0.80:
+ return mixColor(clrViolet, clrPink, (progress-0.55)/0.25)
+ default:
+ return mixColor(clrPink, 0xFFFF8AA8, (progress-0.80)/0.20)
+ }
+}
+
func makeRoundRectPath(x, y, w, h, r float64) uintptr {
var path uintptr
gpCreatePath.Call(fillModeAlternate, uintptr(unsafe.Pointer(&path)))
diff --git a/privatevoice.src/scripts/build-windows-preview.sh b/privatevoice.src/scripts/build-windows-preview.sh
index 680afe1..f715c3d 100755
--- a/privatevoice.src/scripts/build-windows-preview.sh
+++ b/privatevoice.src/scripts/build-windows-preview.sh
@@ -4,8 +4,8 @@
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
-APP_VERSION="2.1.38"
-APP_BUILD="20260629.2014"
+APP_VERSION="2.1.39"
+APP_BUILD="20260701.2356"
ARCH="${1:-amd64}"
if [[ "$ARCH" != "amd64" ]]; then
--
Gitblit v1.9.3