diff --git a/config.go b/config.go index def1793..78ba95b 100644 --- a/config.go +++ b/config.go @@ -61,6 +61,8 @@ type Config struct { HideZodiac bool `json:"hideZodiac"` HideAINews bool `json:"hideAINews"` ShowSeconds bool `json:"showSeconds"` + PhotoDir string `json:"photoDir"` + PhotoInterval int `json:"photoInterval"` KnowledgeKeyword string `json:"knowledgeKeyword"` KnowledgePrompt string `json:"knowledgePrompt"` HideKnowledge bool `json:"hideKnowledge"` diff --git a/dialog.go b/dialog.go index 138f099..2c8aad4 100644 --- a/dialog.go +++ b/dialog.go @@ -12,6 +12,13 @@ var ( comdlg32 = windows.NewLazySystemDLL("comdlg32.dll") procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW") procChooseColorW = comdlg32.NewProc("ChooseColorW") + + shell32 = windows.NewLazySystemDLL("shell32.dll") + procSHBrowseForFolderW = shell32.NewProc("SHBrowseForFolderW") + procSHGetPathFromIDListW = shell32.NewProc("SHGetPathFromIDListW") + + ole32dll = windows.NewLazySystemDLL("ole32.dll") + procCoTaskMemFree = ole32dll.NewProc("CoTaskMemFree") ) func slicePtr(s interface{}) uintptr { @@ -117,3 +124,34 @@ func colorPickerDialog(owner uintptr, initialColor string) string { b := (cc.rgbResult >> 16) & 0xFF return fmt.Sprintf("#%02x%02x%02x", r, g, b) } + +func browseForFolderDialog(owner uintptr) string { + title, _ := windows.UTF16PtrFromString("选择图片目录") + var displayName [260]uint16 + + bi := struct { + HwndOwner uintptr + PidlRoot uintptr + PszDisplayName uintptr + LpszTitle uintptr + UlFlags uint32 + LpFn uintptr + LParam uintptr + IImage int32 + }{ + HwndOwner: owner, + PszDisplayName: uintptr(unsafe.Pointer(&displayName[0])), + LpszTitle: uintptr(unsafe.Pointer(title)), + UlFlags: 0x00000001 | 0x00000040, + } + + pidl, _, _ := procSHBrowseForFolderW.Call(uintptr(unsafe.Pointer(&bi))) + if pidl == 0 { + return "" + } + defer procCoTaskMemFree.Call(pidl) + + var path [260]uint16 + procSHGetPathFromIDListW.Call(pidl, uintptr(unsafe.Pointer(&path[0]))) + return windows.UTF16ToString(path[:]) +} diff --git a/photo.go b/photo.go new file mode 100644 index 0000000..f62d37b --- /dev/null +++ b/photo.go @@ -0,0 +1,166 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +var ( + photoMu sync.Mutex + photoFiles []string + photoIdx int + photoDir string + photoStop chan struct{} + photoDone chan struct{} +) + +func scanPhotoDir(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + switch ext { + case ".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif": + files = append(files, e.Name()) + } + } + sort.Strings(files) + return files +} + +func photoToDataURI(dir, name string) string { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + ext := strings.ToLower(filepath.Ext(name)) + mime := "image/jpeg" + switch ext { + case ".png": + mime = "image/png" + case ".gif": + mime = "image/gif" + case ".webp": + mime = "image/webp" + case ".bmp": + mime = "image/bmp" + } + return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)) +} + +func pushCurrentPhoto(interval int) { + photoMu.Lock() + files := photoFiles + idx := photoIdx + dir := photoDir + photoMu.Unlock() + + if len(files) == 0 || dir == "" { + evalJS(`if(window.updatePhotoFromGo) updatePhotoFromGo(null)`) + return + } + if idx >= len(files) { + idx = 0 + } + + src := photoToDataURI(dir, files[idx]) + if src == "" { + return + } + + data, _ := json.Marshal(map[string]interface{}{ + "src": src, + "counter": fmt.Sprintf("%d / %d", idx+1, len(files)), + "interval": interval, + }) + evalJS(fmt.Sprintf(`if(window.updatePhotoFromGo) updatePhotoFromGo(%s)`, string(data))) +} + +func startPhotoLoop() { + cfg := loadConfig() + if cfg.PhotoDir == "" { + return + } + + interval := cfg.PhotoInterval + if interval <= 0 { + interval = 15 + } + + files := scanPhotoDir(cfg.PhotoDir) + if len(files) == 0 { + log.Println("相册: 目录为空或无图片") + return + } + + photoMu.Lock() + photoDir = cfg.PhotoDir + photoFiles = files + photoIdx = 0 + stop := make(chan struct{}) + done := make(chan struct{}) + photoStop = stop + photoDone = done + photoMu.Unlock() + + log.Printf("相册: 共 %d 张, 间隔 %ds", len(files), interval) + pushCurrentPhoto(interval) + + go func() { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer func() { + ticker.Stop() + close(done) + }() + for { + select { + case <-ticker.C: + photoMu.Lock() + if len(photoFiles) > 0 { + photoIdx = (photoIdx + 1) % len(photoFiles) + } + photoMu.Unlock() + pushCurrentPhoto(interval) + case <-stop: + return + } + } + }() +} + +func stopPhotoLoop() { + photoMu.Lock() + stop := photoStop + done := photoDone + photoStop = nil + photoDone = nil + photoMu.Unlock() + + if stop != nil { + close(stop) + } + if done != nil { + <-done + } +} + +func restartPhotoLoop() { + stopPhotoLoop() + evalJS(`if(window.updatePhotoFromGo) updatePhotoFromGo(null)`) + startPhotoLoop() +} diff --git a/settings.go b/settings.go index 43544bb..5629943 100644 --- a/settings.go +++ b/settings.go @@ -138,6 +138,8 @@ func openSettingsWindow() { "imagePath": cfg.ImagePath, "showSeconds": cfg.ShowSeconds, "ainewsCard": !cfg.HideAINews, + "photoDir": cfg.PhotoDir, + "photoInterval": cfg.PhotoInterval, }) return string(data) }) @@ -364,6 +366,40 @@ func openSettingsWindow() { return "" }) + w.Bind("pickPhotoDir", func() string { + hwnd := uintptr(w.Window()) + dir := browseForFolderDialog(hwnd) + if dir == "" { + return "" + } + cfg := loadConfig() + cfg.PhotoDir = dir + if cfg.PhotoInterval <= 0 { + cfg.PhotoInterval = 15 + } + saveConfig(cfg) + restartPhotoLoop() + return dir + }) + + w.Bind("clearPhotoDir", func() string { + cfg := loadConfig() + cfg.PhotoDir = "" + saveConfig(cfg) + restartPhotoLoop() + return "" + }) + + w.Bind("savePhotoInterval", func(val int) string { + cfg := loadConfig() + cfg.PhotoInterval = val + saveConfig(cfg) + if cfg.PhotoDir != "" { + restartPhotoLoop() + } + return "" + }) + w.SetHtml(settingsHTML) hwnd := uintptr(w.Window()) diff --git a/systray.go b/systray.go index 90bc9cb..1fa9ddd 100644 --- a/systray.go +++ b/systray.go @@ -53,6 +53,7 @@ func onSystrayReady() { go aiNewsLoop() go bingWallpaperLoop() go knowledgeLoop() + go startPhotoLoop() } func startWebView() { diff --git a/web/overlay.html b/web/overlay.html index eb4b571..29e3f97 100644 --- a/web/overlay.html +++ b/web/overlay.html @@ -366,6 +366,55 @@ body.hide-ainews #card-ainews, body.hide-ainews #info .ainews-section { display: none !important; } body.hide-knowledge #card-knowledge, body.hide-knowledge #info .knowledge-section { display: none !important; } + +/* ===== 相册 ===== */ +#card-photo { + position: fixed; + top: 40px; + left: 40px; + width: calc(50vw - 80px); + z-index: 10; + padding: 16px; +} +.photo-wrap { + position: relative; + border-radius: 12px; + overflow: hidden; +} +#card-photo img { + width: 100%; + max-height: 450px; + object-fit: cover; + display: block; + border-radius: 12px; + transition: opacity 0.5s ease; +} +.photo-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 20px 14px 10px; + background: linear-gradient(transparent, rgba(0,0,0,0.5)); +} +.photo-counter { + font-size: 11px; + opacity: 0.7; + display: block; + margin-bottom: 6px; +} +.photo-progress { + height: 3px; + background: rgba(255,255,255,0.15); + border-radius: 2px; + overflow: hidden; +} +.photo-progress-bar { + height: 100%; + width: 0%; + background: rgba(255,255,255,0.6); + border-radius: 2px; +} @@ -429,6 +478,16 @@ body.hide-knowledge #info .knowledge-section { display: none !important; } + +
绝尘
diff --git a/web/settings.html b/web/settings.html index 2f320b8..5346a4f 100644 --- a/web/settings.html +++ b/web/settings.html @@ -330,6 +330,31 @@ input[type="text"]:focus { border-color: var(--input-border-focus); } + +
+
相册
+
+
+
未选择目录
+
+ + +
+
+
+
切换间隔
+ +
+
+
+
个性化
@@ -462,6 +487,26 @@ document.getElementById('themeSelect').addEventListener('change', function() { document.getElementById('textInputRow').style.display = this.value === 'text' ? 'flex' : 'none'; }); +document.getElementById('btnPickPhotoDir').addEventListener('click', function() { + if (!window.pickPhotoDir) return; + window.pickPhotoDir().then(function(dir) { + if (dir) { + document.getElementById('photoDirDisplay').textContent = dir; + document.getElementById('btnClearPhotoDir').style.display = ''; + } + }); +}); +document.getElementById('btnClearPhotoDir').addEventListener('click', function() { + if (!window.clearPhotoDir) return; + window.clearPhotoDir().then(function() { + document.getElementById('photoDirDisplay').textContent = '未选择目录'; + document.getElementById('btnClearPhotoDir').style.display = 'none'; + }); +}); +document.getElementById('photoInterval').addEventListener('change', function() { + if (window.savePhotoInterval) window.savePhotoInterval(parseInt(this.value)); +}); + var textTimer = null; document.getElementById('wallpaperText').addEventListener('input', function() { clearTimeout(textTimer); @@ -672,6 +717,12 @@ if (window.loadAllSettings) { if (s.knowledgeKeyword) document.getElementById('knowledgeKeyword').value = s.knowledgeKeyword; if (s.knowledgePrompt) document.getElementById('knowledgePrompt').value = s.knowledgePrompt; if (s.theme === 'text') document.getElementById('textInputRow').style.display = 'flex'; + // Photo state + if (s.photoDir) { + document.getElementById('photoDirDisplay').textContent = s.photoDir; + document.getElementById('btnClearPhotoDir').style.display = ''; + } + if (s.photoInterval) document.getElementById('photoInterval').value = s.photoInterval; // Color state if (s.color1) { currentColor1 = s.color1; currentColor2 = s.color2 || ''; currentGradient = s.colorGradient || false; } if (s.wallpaperType === 'color') updateColorPreview();