新增: 相册展示模块(左侧幻灯片+进度条+目录选择)
- 左侧固定卡片展示照片幻灯片,淡入淡出切换 - 进度条动画显示当前照片剩余时间 - 设置窗口支持选择图片目录和切换间隔(5/10/15/20/30/60秒) - Win32 SHBrowseForFolderW 目录选择对话框 - Go 端管理幻灯片状态,按间隔推送照片 data URI
This commit is contained in:
@@ -61,6 +61,8 @@ type Config struct {
|
|||||||
HideZodiac bool `json:"hideZodiac"`
|
HideZodiac bool `json:"hideZodiac"`
|
||||||
HideAINews bool `json:"hideAINews"`
|
HideAINews bool `json:"hideAINews"`
|
||||||
ShowSeconds bool `json:"showSeconds"`
|
ShowSeconds bool `json:"showSeconds"`
|
||||||
|
PhotoDir string `json:"photoDir"`
|
||||||
|
PhotoInterval int `json:"photoInterval"`
|
||||||
KnowledgeKeyword string `json:"knowledgeKeyword"`
|
KnowledgeKeyword string `json:"knowledgeKeyword"`
|
||||||
KnowledgePrompt string `json:"knowledgePrompt"`
|
KnowledgePrompt string `json:"knowledgePrompt"`
|
||||||
HideKnowledge bool `json:"hideKnowledge"`
|
HideKnowledge bool `json:"hideKnowledge"`
|
||||||
|
|||||||
38
dialog.go
38
dialog.go
@@ -12,6 +12,13 @@ var (
|
|||||||
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
|
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
|
||||||
procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW")
|
procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW")
|
||||||
procChooseColorW = comdlg32.NewProc("ChooseColorW")
|
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 {
|
func slicePtr(s interface{}) uintptr {
|
||||||
@@ -117,3 +124,34 @@ func colorPickerDialog(owner uintptr, initialColor string) string {
|
|||||||
b := (cc.rgbResult >> 16) & 0xFF
|
b := (cc.rgbResult >> 16) & 0xFF
|
||||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
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[:])
|
||||||
|
}
|
||||||
|
|||||||
166
photo.go
Normal file
166
photo.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
36
settings.go
36
settings.go
@@ -138,6 +138,8 @@ func openSettingsWindow() {
|
|||||||
"imagePath": cfg.ImagePath,
|
"imagePath": cfg.ImagePath,
|
||||||
"showSeconds": cfg.ShowSeconds,
|
"showSeconds": cfg.ShowSeconds,
|
||||||
"ainewsCard": !cfg.HideAINews,
|
"ainewsCard": !cfg.HideAINews,
|
||||||
|
"photoDir": cfg.PhotoDir,
|
||||||
|
"photoInterval": cfg.PhotoInterval,
|
||||||
})
|
})
|
||||||
return string(data)
|
return string(data)
|
||||||
})
|
})
|
||||||
@@ -364,6 +366,40 @@ func openSettingsWindow() {
|
|||||||
return ""
|
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)
|
w.SetHtml(settingsHTML)
|
||||||
|
|
||||||
hwnd := uintptr(w.Window())
|
hwnd := uintptr(w.Window())
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func onSystrayReady() {
|
|||||||
go aiNewsLoop()
|
go aiNewsLoop()
|
||||||
go bingWallpaperLoop()
|
go bingWallpaperLoop()
|
||||||
go knowledgeLoop()
|
go knowledgeLoop()
|
||||||
|
go startPhotoLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebView() {
|
func startWebView() {
|
||||||
|
|||||||
@@ -366,6 +366,55 @@ body.hide-ainews #card-ainews,
|
|||||||
body.hide-ainews #info .ainews-section { display: none !important; }
|
body.hide-ainews #info .ainews-section { display: none !important; }
|
||||||
body.hide-knowledge #card-knowledge,
|
body.hide-knowledge #card-knowledge,
|
||||||
body.hide-knowledge #info .knowledge-section { display: none !important; }
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="layout-{{LAYOUT}}">
|
<body class="layout-{{LAYOUT}}">
|
||||||
@@ -429,6 +478,16 @@ body.hide-knowledge #info .knowledge-section { display: none !important; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="card-photo" class="card" style="display:none">
|
||||||
|
<div class="photo-wrap">
|
||||||
|
<img id="photoImg" src="" alt="">
|
||||||
|
<div class="photo-info">
|
||||||
|
<span class="photo-counter" id="photoCounter"></span>
|
||||||
|
<div class="photo-progress"><div class="photo-progress-bar" id="photoProgress"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="author">绝尘</div>
|
<div id="author">绝尘</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -663,6 +722,31 @@ window.updateWeatherFromGo=function(data){
|
|||||||
|
|
||||||
updateTime();
|
updateTime();
|
||||||
setInterval(updateTime,1000);
|
setInterval(updateTime,1000);
|
||||||
|
|
||||||
|
window.updatePhotoFromGo=function(data){
|
||||||
|
if(typeof data==='string') data=JSON.parse(data);
|
||||||
|
var card=document.getElementById('card-photo');
|
||||||
|
if(!card) return;
|
||||||
|
if(!data||!data.src){card.style.display='none';return;}
|
||||||
|
card.style.display='';
|
||||||
|
var img=document.getElementById('photoImg');
|
||||||
|
var counter=document.getElementById('photoCounter');
|
||||||
|
var bar=document.getElementById('photoProgress');
|
||||||
|
img.style.opacity='0';
|
||||||
|
setTimeout(function(){
|
||||||
|
img.src=data.src;
|
||||||
|
img.onload=function(){img.style.opacity='1';};
|
||||||
|
},300);
|
||||||
|
if(data.counter) counter.textContent=data.counter;
|
||||||
|
bar.style.transition='none';
|
||||||
|
bar.style.width='0%';
|
||||||
|
requestAnimationFrame(function(){
|
||||||
|
requestAnimationFrame(function(){
|
||||||
|
bar.style.transition='width '+(data.interval||15)+'s linear';
|
||||||
|
bar.style.width='100%';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -330,6 +330,31 @@ input[type="text"]:focus { border-color: var(--input-border-focus); }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 相册 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">相册</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-desc" id="photoDirDisplay">未选择目录</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn" id="btnPickPhotoDir">选择目录</button>
|
||||||
|
<button class="btn btn-sm" id="btnClearPhotoDir" style="display:none">清除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-label">切换间隔</div>
|
||||||
|
<select id="photoInterval">
|
||||||
|
<option value="5">5 秒</option>
|
||||||
|
<option value="10">10 秒</option>
|
||||||
|
<option value="15" selected>15 秒</option>
|
||||||
|
<option value="20">20 秒</option>
|
||||||
|
<option value="30">30 秒</option>
|
||||||
|
<option value="60">60 秒</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 星座 + 城市 -->
|
<!-- 星座 + 城市 -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-label">个性化</div>
|
<div class="section-label">个性化</div>
|
||||||
@@ -462,6 +487,26 @@ document.getElementById('themeSelect').addEventListener('change', function() {
|
|||||||
document.getElementById('textInputRow').style.display = this.value === 'text' ? 'flex' : 'none';
|
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;
|
var textTimer = null;
|
||||||
document.getElementById('wallpaperText').addEventListener('input', function() {
|
document.getElementById('wallpaperText').addEventListener('input', function() {
|
||||||
clearTimeout(textTimer);
|
clearTimeout(textTimer);
|
||||||
@@ -672,6 +717,12 @@ if (window.loadAllSettings) {
|
|||||||
if (s.knowledgeKeyword) document.getElementById('knowledgeKeyword').value = s.knowledgeKeyword;
|
if (s.knowledgeKeyword) document.getElementById('knowledgeKeyword').value = s.knowledgeKeyword;
|
||||||
if (s.knowledgePrompt) document.getElementById('knowledgePrompt').value = s.knowledgePrompt;
|
if (s.knowledgePrompt) document.getElementById('knowledgePrompt').value = s.knowledgePrompt;
|
||||||
if (s.theme === 'text') document.getElementById('textInputRow').style.display = 'flex';
|
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
|
// Color state
|
||||||
if (s.color1) { currentColor1 = s.color1; currentColor2 = s.color2 || ''; currentGradient = s.colorGradient || false; }
|
if (s.color1) { currentColor1 = s.color1; currentColor2 = s.color2 || ''; currentGradient = s.colorGradient || false; }
|
||||||
if (s.wallpaperType === 'color') updateColorPreview();
|
if (s.wallpaperType === 'color') updateColorPreview();
|
||||||
|
|||||||
Reference in New Issue
Block a user