// Created by GoLand.
// User: Chaoyang Kong
// AI: ChatGPT4.0
// Date: 2025-04-15
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"golang.org/x/sys/windows"
)
// --------------------- 定义变量 ---------------------
const configFileName = "config.json"
// 固定各列宽度与行高度
var (
colWidthCheck = 50
colWidthSong = 400 // 文件名显示区域增大
colWidthTime = 200
colWidthRepeat = 100
colWidthStatus = 100
cellHeight = 30
)
var (
schedules []*SongSchedule
scheduleMux sync.Mutex
taskListContainer *fyne.Container // 用于展示任务的容器(VBox)
)
// 初始化互斥变量
var singleInstanceMutex windows.Handle
// TimeNode 表示一个时间节点(小时、分钟)
type TimeNode struct {
Hour int `json:"hour"`
Minute int `json:"minute"`
}
// ConfigTask 用于配置保存,只持久化必要数据
type ConfigTask struct {
FilePath string `json:"file_path"`
TimeNodes []TimeNode `json:"time_nodes"`
Days []int `json:"days"` // 存储 weekday 的 int 值
RepeatCount int `json:"repeat_count"` // 重复播放次数
Enabled bool `json:"enabled"` // 运行任务布尔值
}
// SongSchedule 表示一首歌曲的定时任务
type SongSchedule struct {
FilePath string
TimeNodes []TimeNode // 任务触发的多个时间节点
Days []time.Weekday // 播放的星期
RepeatCount int // 重复播放次数(1 表示只播放一次)
StopChan chan struct{} // 任务运行时非 nil,停止后置为 nil
CurrentCmd *exec.Cmd
CurrentMutex sync.Mutex
Selected bool // UI 选中状态
}
// --------------------- 各窗口,按钮,控件实现 ---------------------
// 保存配置到本地文件
func saveConfig() {
scheduleMux.Lock()
defer scheduleMux.Unlock()
var configTasks []ConfigTask
for _, s := range schedules {
var days []int
for _, d := range s.Days {
days = append(days, int(d))
}
enabled := s.StopChan != nil
configTasks = append(configTasks, ConfigTask{
FilePath: s.FilePath,
TimeNodes: s.TimeNodes,
Days: days,
RepeatCount: s.RepeatCount,
Enabled: enabled,
})
}
data, err := json.MarshalIndent(configTasks, "", " ")
if err != nil {
log.Println("保存配置错误:", err)
return
}
if err = ioutil.WriteFile(configFileName, data, 0666); err != nil {
log.Println("写入配置文件错误:", err)
}
}
// 从本地配置文件加载任务数据
func loadConfig() {
data, err := ioutil.ReadFile(configFileName)
if err != nil {
if os.IsNotExist(err) {
log.Println("未找到配置文件,使用空配置")
return
}
log.Println("读取配置文件错误:", err)
return
}
var configTasks []ConfigTask
if err = json.Unmarshal(data, &configTasks); err != nil {
log.Println("解析配置文件错误:", err)
return
}
scheduleMux.Lock()
defer scheduleMux.Unlock()
for _, ct := range configTasks {
var days []time.Weekday
for _, d := range ct.Days {
days = append(days, time.Weekday(d))
}
s := &SongSchedule{
FilePath: ct.FilePath,
TimeNodes: ct.TimeNodes,
Days: days,
RepeatCount: ct.RepeatCount,
Selected: false,
}
if ct.Enabled {
s.StopChan = make(chan struct{})
go scheduleMusic(s)
} else {
s.StopChan = nil
}
schedules = append(schedules, s)
}
log.Println("配置文件加载成功,任务数量:", len(schedules))
}
// 仅显示文件名,不显示完整目录
func fileNameShort(path string) string {
return filepath.Base(path)
}
// 格式化时间节点为 "HH:MM" 字符串列表
func formatTimeNodes(nodes []TimeNode) string {
var parts []string
for _, n := range nodes {
parts = append(parts, fmt.Sprintf("%02d:%02d", n.Hour, n.Minute))
}
return strings.Join(parts, ", ")
}
// 生成单行任务显示控件,各单元格采用固定尺寸包装,保持与表头对齐
func newScheduleRow(s *SongSchedule) fyne.CanvasObject {
check := widget.NewCheck("", func(b bool) { s.Selected = b })
check.SetChecked(s.Selected)
lblSong := widget.NewLabel(fileNameShort(s.FilePath))
lblSong.Wrapping = fyne.TextWrapBreak
lblTime := widget.NewLabel(formatTimeNodes(s.TimeNodes))
lblRepeat := widget.NewLabel(fmt.Sprintf("×%d", s.RepeatCount))
status := "已停止"
if s.StopChan != nil {
status = "已启用"
}
lblStatus := widget.NewLabel(status)
cell1 := container.NewGridWrap(fyne.NewSize(float32(colWidthCheck), float32(cellHeight)), check)
cell2 := container.NewGridWrap(fyne.NewSize(float32(colWidthSong), float32(cellHeight)), lblSong)
cell3 := container.NewGridWrap(fyne.NewSize(float32(colWidthTime), float32(cellHeight)), lblTime)
cell4 := container.NewGridWrap(fyne.NewSize(float32(colWidthRepeat), float32(cellHeight)), lblRepeat)
cell5 := container.NewGridWrap(fyne.NewSize(float32(colWidthStatus), float32(cellHeight)), lblStatus)
return container.NewHBox(cell1, cell2, cell3, cell4, cell5)
}
// 刷新任务列表显示,并保存配置
func refreshTaskList() {
taskListContainer.Objects = nil
scheduleMux.Lock()
for _, s := range schedules {
taskListContainer.Add(newScheduleRow(s))
}
scheduleMux.Unlock()
taskListContainer.Refresh()
saveConfig()
}
// 根据当前时间、时间节点及星期计算下一次任务触发时间
func getNextTargetTime(now time.Time, nodes []TimeNode, days []time.Weekday) time.Time {
var next time.Time
found := false
for d := 0; d < 8; d++ {
dayCandidate := now.AddDate(0, 0, d)
if !weekdaySelected(dayCandidate.Weekday(), days) {
continue
}
for _, n := range nodes {
candidate := time.Date(dayCandidate.Year(), dayCandidate.Month(), dayCandidate.Day(), n.Hour, n.Minute, 0, 0, dayCandidate.Location())
if candidate.After(now) || d > 0 {
if !found || candidate.Before(next) {
next = candidate
found = true
}
}
}
}
return next
}
// 判断指定的星期是否在 days 列表中
func weekdaySelected(day time.Weekday, days []time.Weekday) bool {
for _, d := range days {
if d == day {
return true
}
}
return false
}
// --------------------- 播放实现 ---------------------
// 调用 VLC 播放歌曲
func playSong(s *SongSchedule) {
s.CurrentMutex.Lock()
defer s.CurrentMutex.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log.Printf("正在播放歌曲: %s, 重复次数: %d", fileNameShort(s.FilePath), s.RepeatCount)
for i := 0; i < s.RepeatCount; i++ {
log.Printf("歌曲 %s 第 %d 次播放…", fileNameShort(s.FilePath), i+1)
cmd := exec.CommandContext(ctx, "vlc.exe", "--play-and-exit", filepath.FromSlash(s.FilePath))
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
s.CurrentCmd = cmd
if err := cmd.Run(); err != nil {
log.Printf("播放歌曲 %s 出错: %v", fileNameShort(s.FilePath), err)
return
}
}
log.Printf("歌曲 %s 播放完成", fileNameShort(s.FilePath))
}
// 终止当前 VLC 播放进程
func killProcess(s *SongSchedule) {
s.CurrentMutex.Lock()
defer s.CurrentMutex.Unlock()
if s.CurrentCmd != nil && s.CurrentCmd.Process != nil {
s.CurrentCmd.Process.Kill()
}
}
// 定时任务调度,不断等待下一个触发点,播放歌曲
func scheduleMusic(s *SongSchedule) {
for {
now := time.Now()
next := getNextTargetTime(now, s.TimeNodes, s.Days)
waitDuration := time.Until(next)
log.Printf("歌曲 %s 将在 %v 后播放 (目标时间: %v)", fileNameShort(s.FilePath), waitDuration, next)
select {
case <-time.After(waitDuration):
case <-s.StopChan:
log.Printf("歌曲 %s 的任务收到停止信号", fileNameShort(s.FilePath))
return
}
select {
case <-s.StopChan:
return
default:
playSong(s)
}
select {
case <-time.After(5 * time.Second):
case <-s.StopChan:
return
}
}
}
// 弹出添加任务对话框,确认后调用 onConfirm 回调(一般用于刷新列表)
func showAddSongDialog(parent fyne.Window, onConfirm func()) {
var d dialog.Dialog
// 当点击“确认添加”时,先刷新任务列表,再隐藏对话框
content := createSongConfigUI(parent, func() {
onConfirm()
d.Hide()
})
// 设置较大最小尺寸,避免内容压缩
if scroll, ok := content.(*container.Scroll); ok {
scroll.SetMinSize(fyne.NewSize(600, 500))
}
d = dialog.NewCustom("增加定时任务", "退出", content, parent)
d.Show()
}
// 构建添加任务界面,确认后调用 onConfirm 回调
func createSongConfigUI(parent fyne.Window, onConfirm func()) fyne.CanvasObject {
var musicFile string
var timeNodes []TimeNode
selectedFileLabel := widget.NewLabel("未选择歌曲文件")
fileSelector := widget.NewButton("选择歌曲文件", func() {
fileDialog := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, parent)
return
}
if reader == nil {
return
}
musicFile = filepath.FromSlash(reader.URI().Path())
selectedFileLabel.SetText(filepath.Base(reader.URI().Name()))
reader.Close()
}, parent)
musicFilter := storage.NewExtensionFileFilter([]string{".mp3", ".wav", ".flac"})
fileDialog.SetFilter(musicFilter)
fileDialog.Show()
})
hours := createNumberRange(0, 23)
minutes := createNumberRange(0, 59)
hourSelector := widget.NewSelect(hours, nil)
minuteSelector := widget.NewSelect(minutes, nil)
hourSelector.SetSelected("00")
minuteSelector.SetSelected("00")
currentTimeLabel := widget.NewLabel("当前选择时间: 00:00")
updateCurrentTime := func() {
currentTimeLabel.SetText("当前选择时间: " + hourSelector.Selected + ":" + minuteSelector.Selected)
}
hourSelector.OnChanged = func(s string) { updateCurrentTime() }
minuteSelector.OnChanged = func(s string) { updateCurrentTime() }
// 使用容器展示已添加的时间节点,支持删除
timeNodesContainer := container.NewVBox()
var updateTimeNodesContainer func()
updateTimeNodesContainer = func() {
timeNodesContainer.Objects = nil
for i, tn := range timeNodes {
idx := i
ts := fmt.Sprintf("%02d:%02d", tn.Hour, tn.Minute)
delBtn := widget.NewButton("删除", func() {
timeNodes = append(timeNodes[:idx], timeNodes[idx+1:]...)
updateTimeNodesContainer()
})
row := container.NewHBox(widget.NewLabel(ts), delBtn)
timeNodesContainer.Add(row)
}
timeNodesContainer.Refresh()
}
// 立即加载已有数据
updateTimeNodesContainer()
addTimeNodeButton := widget.NewButton("添加时间节点", func() {
h, err1 := strconv.Atoi(hourSelector.Selected)
m, err2 := strconv.Atoi(minuteSelector.Selected)
if err1 != nil || err2 != nil {
dialog.ShowInformation("错误", "时间格式转换错误", parent)
return
}
timeNodes = append(timeNodes, TimeNode{Hour: h, Minute: m})
updateTimeNodesContainer()
})
daysData := []struct {
Label string
Day time.Weekday
}{
{"星期一", time.Monday},
{"星期二", time.Tuesday},
{"星期三", time.Wednesday},
{"星期四", time.Thursday},
{"星期五", time.Friday},
{"星期六", time.Saturday},
{"星期日", time.Sunday},
}
var dayCheckboxes []*widget.Check
for _, d := range daysData {
cb := widget.NewCheck(d.Label, nil)
dayCheckboxes = append(dayCheckboxes, cb)
}
weekdaysContainer := container.NewGridWithColumns(3,
dayCheckboxes[0], dayCheckboxes[1], dayCheckboxes[2],
dayCheckboxes[3], dayCheckboxes[4], dayCheckboxes[5],
dayCheckboxes[6],
)
repeatEntry := widget.NewEntry()
repeatEntry.SetPlaceHolder("重复次数 (1表示不重复)")
repeatEntry.SetText("1")
confirmBtn := widget.NewButton("确认添加", func() {
rc, err := strconv.Atoi(repeatEntry.Text)
if err != nil || rc < 1 {
dialog.ShowInformation("错误", "重复次数必须为正整数", parent)
return
}
if musicFile == "" {
dialog.ShowInformation("错误", "请先选择歌曲文件", parent)
return
}
if len(timeNodes) == 0 {
dialog.ShowInformation("错误", "请先添加至少一个时间节点", parent)
return
}
var selectedDays []time.Weekday
for _, cb := range dayCheckboxes {
if cb.Checked {
switch cb.Text {
case "星期一":
selectedDays = append(selectedDays, time.Monday)
case "星期二":
selectedDays = append(selectedDays, time.Tuesday)
case "星期三":
selectedDays = append(selectedDays, time.Wednesday)
case "星期四":
selectedDays = append(selectedDays, time.Thursday)
case "星期五":
selectedDays = append(selectedDays, time.Friday)
case "星期六":
selectedDays = append(selectedDays, time.Saturday)
case "星期日":
selectedDays = append(selectedDays, time.Sunday)
}
}
}
if len(selectedDays) == 0 {
selectedDays = []time.Weekday{
time.Monday, time.Tuesday, time.Wednesday,
time.Thursday, time.Friday, time.Saturday, time.Sunday,
}
}
newTask := &SongSchedule{
FilePath: musicFile,
TimeNodes: timeNodes,
Days: selectedDays,
RepeatCount: rc,
StopChan: make(chan struct{}),
Selected: false,
}
go scheduleMusic(newTask)
scheduleMux.Lock()
schedules = append(schedules, newTask)
scheduleMux.Unlock()
onConfirm()
})
form := container.NewVBox(
fileSelector, selectedFileLabel,
container.NewHBox(widget.NewLabel("选择时间:"), hourSelector, minuteSelector, currentTimeLabel, addTimeNodeButton),
widget.NewLabel("已添加时间节点:"), timeNodesContainer,
widget.NewLabel("选择播放的星期:"), weekdaysContainer,
widget.NewLabel("重复次数:"), repeatEntry,
confirmBtn,
)
scroll := container.NewVScroll(form)
scroll.SetMinSize(fyne.NewSize(600, 500))
return scroll
}
// 弹出编辑任务对话框,仅允许选中一个任务进行编辑;编辑完成后调用 onConfirm 并关闭对话框
func showEditSongDialog(parent fyne.Window, task *SongSchedule, onConfirm func()) {
var d dialog.Dialog
content := createSongEditUI(parent, task, func() {
onConfirm()
d.Hide()
})
d = dialog.NewCustom("编辑任务", "取消", content, parent)
d.Show()
}
// 构建编辑任务的 UI 界面,预填任务数据;确认后调用 onConfirm 回调更新任务数据,也增加了时间节点的删除支持(原理同上)
func createSongEditUI(parent fyne.Window, task *SongSchedule, onConfirm func()) fyne.CanvasObject {
musicFile := task.FilePath
timeNodes := make([]TimeNode, len(task.TimeNodes))
copy(timeNodes, task.TimeNodes)
selectedDays := make([]time.Weekday, len(task.Days))
copy(selectedDays, task.Days)
repeatValue := strconv.Itoa(task.RepeatCount)
selectedFileLabel := widget.NewLabel(filepath.Base(musicFile))
fileSelector := widget.NewButton("选择歌曲文件", func() {
fileDialog := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, parent)
return
}
if reader == nil {
return
}
musicFile = filepath.FromSlash(reader.URI().Path())
selectedFileLabel.SetText(filepath.Base(reader.URI().Name()))
reader.Close()
}, parent)
musicFilter := storage.NewExtensionFileFilter([]string{".mp3", ".wav", ".flac"})
fileDialog.SetFilter(musicFilter)
fileDialog.Show()
})
initialHour := "00"
initialMinute := "00"
if len(timeNodes) > 0 {
initialHour = fmt.Sprintf("%02d", timeNodes[0].Hour)
initialMinute = fmt.Sprintf("%02d", timeNodes[0].Minute)
}
hours := createNumberRange(0, 23)
minutes := createNumberRange(0, 59)
hourSelector := widget.NewSelect(hours, nil)
minuteSelector := widget.NewSelect(minutes, nil)
hourSelector.SetSelected(initialHour)
minuteSelector.SetSelected(initialMinute)
currentTimeLabel := widget.NewLabel("当前选择时间: " + hourSelector.Selected + ":" + minuteSelector.Selected)
updateCurrentTime := func() {
currentTimeLabel.SetText("当前选择时间: " + hourSelector.Selected + ":" + minuteSelector.Selected)
}
hourSelector.OnChanged = func(s string) { updateCurrentTime() }
minuteSelector.OnChanged = func(s string) { updateCurrentTime() }
// 时间节点容器,支持删除
timeNodesContainer := container.NewVBox()
// 声明并赋值 updateTimeNodesContainer
var updateTimeNodesContainer func()
updateTimeNodesContainer = func() {
timeNodesContainer.Objects = nil
for i, tn := range timeNodes {
idx := i // 捕获当前索引
ts := fmt.Sprintf("%02d:%02d", tn.Hour, tn.Minute)
delBtn := widget.NewButton("删除", func() {
timeNodes = append(timeNodes[:idx], timeNodes[idx+1:]...)
updateTimeNodesContainer()
})
row := container.NewHBox(widget.NewLabel(ts), delBtn)
timeNodesContainer.Add(row)
}
timeNodesContainer.Refresh()
}
// 调用 updateTimeNodesContainer() 加载 pre‑存在的时间节点
updateTimeNodesContainer()
addTimeNodeButton := widget.NewButton("添加时间节点", func() {
h, err1 := strconv.Atoi(hourSelector.Selected)
m, err2 := strconv.Atoi(minuteSelector.Selected)
if err1 != nil || err2 != nil {
dialog.ShowInformation("错误", "时间格式转换错误", parent)
return
}
timeNodes = append(timeNodes, TimeNode{Hour: h, Minute: m})
updateTimeNodesContainer()
})
daysData := []struct {
Label string
Day time.Weekday
}{
{"星期一", time.Monday},
{"星期二", time.Tuesday},
{"星期三", time.Wednesday},
{"星期四", time.Thursday},
{"星期五", time.Friday},
{"星期六", time.Saturday},
{"星期日", time.Sunday},
}
var dayCheckboxes []*widget.Check
for _, dData := range daysData {
cb := widget.NewCheck(dData.Label, nil)
for _, d := range selectedDays {
if d == dData.Day {
cb.SetChecked(true)
break
}
}
dayCheckboxes = append(dayCheckboxes, cb)
}
weekdaysContainer := container.NewGridWithColumns(3,
dayCheckboxes[0], dayCheckboxes[1], dayCheckboxes[2],
dayCheckboxes[3], dayCheckboxes[4], dayCheckboxes[5],
dayCheckboxes[6],
)
repeatEntry := widget.NewEntry()
repeatEntry.SetPlaceHolder("重复次数 (1表示不重复)")
repeatEntry.SetText(repeatValue)
confirmBtn := widget.NewButton("确认编辑", func() {
rc, err := strconv.Atoi(repeatEntry.Text)
if err != nil || rc < 1 {
dialog.ShowInformation("错误", "重复次数必须为正整数", parent)
return
}
if musicFile == "" {
dialog.ShowInformation("错误", "请先选择歌曲文件", parent)
return
}
if len(timeNodes) == 0 {
dialog.ShowInformation("错误", "请先添加至少一个时间节点", parent)
return
}
var newSelectedDays []time.Weekday
for _, cb := range dayCheckboxes {
if cb.Checked {
switch cb.Text {
case "星期一":
newSelectedDays = append(newSelectedDays, time.Monday)
case "星期二":
newSelectedDays = append(newSelectedDays, time.Tuesday)
case "星期三":
newSelectedDays = append(newSelectedDays, time.Wednesday)
case "星期四":
newSelectedDays = append(newSelectedDays, time.Thursday)
case "星期五":
newSelectedDays = append(newSelectedDays, time.Friday)
case "星期六":
newSelectedDays = append(newSelectedDays, time.Saturday)
case "星期日":
newSelectedDays = append(newSelectedDays, time.Sunday)
}
}
}
if len(newSelectedDays) == 0 {
newSelectedDays = []time.Weekday{
time.Monday, time.Tuesday, time.Wednesday,
time.Thursday, time.Friday, time.Saturday, time.Sunday,
}
}
task.FilePath = musicFile
task.TimeNodes = timeNodes
task.Days = newSelectedDays
task.RepeatCount = rc
onConfirm()
})
form := container.NewVBox(
fileSelector, selectedFileLabel,
container.NewHBox(widget.NewLabel("选择时间:"), hourSelector, minuteSelector, currentTimeLabel, addTimeNodeButton),
widget.NewLabel("已添加时间节点:"), timeNodesContainer,
widget.NewLabel("选择播放的星期:"), weekdaysContainer,
widget.NewLabel("重复次数:"), repeatEntry,
confirmBtn,
)
scroll := container.NewVScroll(form)
scroll.SetMinSize(fyne.NewSize(600, 500))
return scroll
}
// --------------------- 系统托盘集成 ---------------------
// setupSystemTray 利用 Fyne 内置桌面扩展(desktop.Driver)设置系统托盘图标及菜单
func setupSystemTray(mainWindow fyne.Window, a fyne.App) {
// 尝试将 a 转换为 desktop.App
if d, ok := a.(desktop.App); ok {
// 设置托盘图标:提供 Base64 编码的 PNG 数据(请替换为你自己的数据)
var trayIconBase64 = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAHmElEQVR4nO1bWWxVVRRdL9IHyFiwDFEQ2orBAVD/VFALEgesRr+UQRQFNHFAKIMmGEc0/hnUVoZEJUSjxBhB8EehgBFQUSCKiPCh4AQVBMQO9JqdrJPsnNxz5/f6SLqSmzbn7jPtu88ezwM60YlCIg9gLID5AFYA2ALgZwBNAJr5NLFN3i0n7Vj2PSvRC8C9ADYAOAXAS/hI3/UApgHoibMAFwFYBuCk2kQ7gO8AvApgFoDrAVQCKOcXzvP/KgA3kEZod7GvGeckx65GCWIYgNUA2tSmNwK4H0BFinGl7wwAmxQzZI5VAIaiBJAHsEiJ+X8AGvg1s0Y1JaBZScRCAGXoIFQB+Ep98XcAnF+EeYdQ2szR2M4jVVTcAeAYF/ATz3UQ+gCoBfAygLUA9gL4i+JsrMD3AD4BMDLiGmpoOWQNf3P8ouAhAGc48XsAejvougK4m1rc6IYoz2zVfwmAyQHmUBj7vtINokALiieVyC9w0HQDMAfAr2pTrQC2AngWwJ0ARgPoT3NplN3lAMYBOIdt16r+hwBMD1mXUZKiFwr25T1y+j4HzS0A9quF7wTwCM1cXOS46V0ca3cI/QwlabMKcebPkMt+m+8OoF5tfHfGZ/JqAMP5v0jITZzTxgNcY1uW81cphecn9oMBfKPMYJ0S40JgOufaBmCAz/unlGI0TEuMvDJ1ovBsVCpNLKI/CsUxg/uVBfIzgR8oE5nKT1ikJrK1/SC1+R0ABqJ4GMg5DePtufsCOMD3Elgldm9P8UzZdr67EvvtCQIVifqOAmik7hBFOZ5MjYqenNujlIr10ZigPMZEbvNqDiAeno0GvtuX0NcP8gPiMKaC0in9Xvd5/y7fvZ0kqmujUrPd21oOepr2PAnMZuUrPUqGbqZH6GLMp46xruA6heZW691QepqtceOTZRxQFmaLvjlbjyM5zKbgsCo2Y4LoBU8oXSUepMZyx16c6MVz0+7DtQUcTOL7LkiOsA3Fpe9C38MjM2xplr2cANAjjp3daLULZw/znTgiKCEGGC9UaH7xiR0a+W5KlMk2kFiSGRpT2f4t3dRSY0COkil091jvHmS7RKKByCvTV+FgzEykh72hS3juB0ekd2E26dZZ7QPVMQh0jMapM67Rj5q0mf+nhb0hrf2bqPgaqAgnxGDAeQBa+IgzpGF0xDVBA8wnkSQlNW5n++fIBvaGtA8Q5CNEgTnvk6z2pWyfF9R5hSOcfIXtz6CwDACPwXg6QfXc0FEmRqPgeY71kiOcFxPvxFYSXWe1r2O7SEKhGZAWd3Gsj3xSaB4Z6sRBEkkcoPEj2y89CxgwimNJjlGjku0SwDlxlET9HO2Sxip1BgzgWH/4KEiPyVgnmklkOxKn2W5HXGG4EcBKBk1iXo8D2FNgBnTnWP/6OHIe44aCM2CY0sauR+YqOQYccYi6aRcxCoM4NX+S/jAztpcBOJe++Bia2ykFPgKyhthH4CCJ7FzaXrbLRsK4b+LzdT7OiAtZMmB0GiW4hUR2Bmgt2yU7jAhh6c6Ytf2SMYMrQhwhKWwEYRvpbou56CwZ8EIaR6jO4QrXOkJkG8dJJyWrjmLAZkd26DW2zw3qPJZEUo3RKGcw1BLiCxxLwIC+7CN906JCrdNew54owVA+IBxe7zgeGl8mOAIm0PoC6THbEfcP4p7+iVInWM9BpNamMUWFyq6EyBylBO3cnB+6qiTGY0iHnKoj2gmRmWz/OMpA00i8yWexh/juZkffbvT6jBkMKoqW8z6ARzMbhWFBmBSQEjN6QUrtkQoOJyky1Q4luSsgKTpSOUK/sVY3SjlCo9n2u/LZL0Y6lKkzbmerR8RNigre5GDy1/7Cphwm4u7ChbQYXsjzWUaXneZxvH0+krSS7yS3EBnV6vqKFCP9RC1KYaSGefm9lCp5fqAtlitxWeBKVRiRzLAdk7TQMsS+R7SKg0qJzMYbiuNprsFl4febSrHYeRvm+sxbSQYfqi48ype0ff6v+W57nLOVIXqo4ugOn0h1It/J2b8g6SQLVADRxyfVvN9RQygGZgSUx8tVYCeKOzHyissiTn7KbrFPBkk8yucyuuzclWPJpSkNmfNpn/Sd+AMfcs3bsrhIWcnrJh5j+zD0ViYuafXYrv56HNN1JU9jsaov2MxJjFpahXYfD9HGEocjVceILAwPW2KbU9mlF0P6Go+vzacukBqz1OByG8uFBsYT+usPVxkiOw0vOQiNwz5JmTFMcdWHbN58pKD1pcJCLq6d3pwfcj5XZkxxck2EMHgN26SPRk9HDJJTYt+e5k5QHEkwFxLXREx7mRumUmEOY8DUGDc+y5XCC5PMTFGrFOMBFjCDIJr4Kp8vuIWBikaOtGHae6IydU2FOPNhGK5MpMcLScX4EcMw5eEZU5eZto+LMp454zE20/eXaylZYwQDmxbl4dV15A8mNIbwOl2rUkaNVGRpLlAOonbfrG6Dt9K3T+zeFhJVNIMnlIi283LCUqasahht9lc/murPthr6CkutEprHdFZ9R/w6JGmgMpW5Oc2MuM8JprEmd1DAlQnKmImdxzxAI4OrI+qHk0fYtolJmLnsk+YqXic60YlOIAr+B8+39Dr0YEQSAAAAAElFTkSuQmCC"
trayIconBytes, err := base64.StdEncoding.DecodeString(trayIconBase64)
if err != nil {
log.Println("解码托盘图标失败:", err)
} else {
trayIconResource := fyne.NewStaticResource("tray.png", trayIconBytes)
d.SetSystemTrayIcon(trayIconResource)
}
// 设置托盘菜单
trayMenu := fyne.NewMenu("定时播放器",
fyne.NewMenuItem("显示主窗口", func() {
mainWindow.Show()
}),
fyne.NewMenuItem("退出", func() {
a.Quit()
os.Exit(0)
}),
)
d.SetSystemTrayMenu(trayMenu)
} else {
log.Println("当前应用不支持桌面托盘功能")
}
}
// --------------------- 主程序入口 ---------------------
func main() {
// 检查是否只有一个实例在运行(仅适用于 Windows)
if err := ensureSingleInstance(); err != nil {
dialog.ShowError(err, nil)
return
}
// 初始化日志
logFile, err := initLogger()
if err != nil {
log.Println("无法创建日志文件:", err)
return
}
defer logFile.Close()
log.Println("===== 定时播放器启动 =====")
// 先加载之前保存的配置
loadConfig()
a := app.NewWithID("com.example.autoplaymusic")
a.Settings().SetTheme(theme.DefaultTheme())
mainWindow := a.NewWindow("定时播放器")
mainWindow.Resize(fyne.NewSize(1000, 800))
// 拦截关闭事件:隐藏窗口而非退出应用
mainWindow.SetCloseIntercept(func() {
mainWindow.Hide()
})
taskListContainer = container.NewVBox()
taskListScroll := container.NewVScroll(taskListContainer)
// 顶部按钮栏
addButton := widget.NewButton("添加新的歌曲定时", func() {
showAddSongDialog(mainWindow, refreshTaskList)
})
stopSelectedButton := widget.NewButton("停止已选任务", func() {
// 先检查是否选中了至少一个任务
scheduleMux.Lock()
var tasksToStop []*SongSchedule
for _, s := range schedules {
if s.Selected {
tasksToStop = append(tasksToStop, s)
}
}
scheduleMux.Unlock()
if len(tasksToStop) == 0 {
dialog.ShowInformation("提示", "请先选择至少一个任务", mainWindow)
return
}
// 对每个要停止的任务进行停止操作
for _, s := range tasksToStop {
// 通知任务停止
// 注意:关闭 channel 后不能再往里面发送数据
if s.StopChan != nil {
close(s.StopChan)
s.StopChan = nil
}
killProcess(s)
// 同时将选中状态取消
s.Selected = false
}
// 刷新任务列表,此时不再持有 scheduleMux 锁
refreshTaskList()
})
enableSelectedButton := widget.NewButton("启用已选任务", func() {
// 先检查是否选中了至少一个任务
scheduleMux.Lock()
var tasksToEnable []*SongSchedule
for _, s := range schedules {
if s.Selected {
tasksToEnable = append(tasksToEnable, s)
}
}
scheduleMux.Unlock()
if len(tasksToEnable) == 0 {
dialog.ShowInformation("提示", "请先选择至少一个任务", mainWindow)
return
}
// 启用任务:启动时不在持有 scheduleMux 锁
for _, s := range tasksToEnable {
if s.StopChan == nil {
s.StopChan = make(chan struct{})
go scheduleMusic(s)
}
s.Selected = false
}
refreshTaskList()
})
deleteSelectedButton := widget.NewButton("删除已选任务", func() {
// 检查是否选中了至少一个任务
scheduleMux.Lock()
var tasksToDelete []*SongSchedule
for _, s := range schedules {
if s.Selected {
tasksToDelete = append(tasksToDelete, s)
}
}
scheduleMux.Unlock()
if len(tasksToDelete) == 0 {
dialog.ShowInformation("提示", "请先选择至少一个任务", mainWindow)
return
}
// 删除前,先停止被选中的任务
for _, s := range tasksToDelete {
if s.StopChan != nil {
close(s.StopChan)
s.StopChan = nil
killProcess(s)
}
}
// 更新 schedules 列表:排除被选中的任务
scheduleMux.Lock()
var newSchedules []*SongSchedule
for _, s := range schedules {
if !s.Selected {
newSchedules = append(newSchedules, s)
}
}
schedules = newSchedules
scheduleMux.Unlock()
refreshTaskList()
})
editSelectedButton := widget.NewButton("编辑已选任务", func() {
scheduleMux.Lock()
var selected []*SongSchedule
for _, s := range schedules {
if s.Selected {
selected = append(selected, s)
}
}
scheduleMux.Unlock()
if len(selected) != 1 {
dialog.ShowInformation("提示", "编辑任务只能选择一个,请选择一个任务进行编辑", mainWindow)
return
}
showEditSongDialog(mainWindow, selected[0], refreshTaskList)
})
buttonBar := container.NewHBox(addButton, stopSelectedButton, enableSelectedButton, deleteSelectedButton, editSelectedButton)
// 表头,与任务行固定尺寸保持一致
headerBar := container.NewHBox(
container.NewGridWrap(fyne.NewSize(float32(colWidthCheck), float32(cellHeight)),
widget.NewLabelWithStyle("选择", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})),
container.NewGridWrap(fyne.NewSize(float32(colWidthSong), float32(cellHeight)),
widget.NewLabelWithStyle("歌曲名称", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})),
container.NewGridWrap(fyne.NewSize(float32(colWidthTime), float32(cellHeight)),
widget.NewLabelWithStyle("定时时间", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})),
container.NewGridWrap(fyne.NewSize(float32(colWidthRepeat), float32(cellHeight)),
widget.NewLabelWithStyle("重复次数", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})),
container.NewGridWrap(fyne.NewSize(float32(colWidthStatus), float32(cellHeight)),
widget.NewLabelWithStyle("是否启用", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})),
)
top := container.NewVBox(buttonBar, headerBar)
content := container.NewBorder(top, nil, nil, nil, taskListScroll)
mainWindow.SetContent(content)
// 刷新任务列表,加载配置后自动显示任务(任务默认为停止状态)
refreshTaskList()
// 设置系统托盘(利用 Fyne 内置桌面扩展)
setupSystemTray(mainWindow, a)
a.Run()
}
// --------------------- 初始化日志 ---------------------
func initLogger() (*os.File, error) {
logFile, err := os.OpenFile("AutoPlayMusic.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
log.SetOutput(io.MultiWriter(logFile, os.Stdout))
return logFile, nil
}
// --------------------- 生成数字范围 ---------------------
func createNumberRange(start, end int) []string {
var res []string
for i := start; i <= end; i++ {
res = append(res, fmt.Sprintf("%02d", i))
}
return res
}
// --------------------- 互斥实现---------------------
func ensureSingleInstance() error {
mutexName := "Local\\AutoPlayMusicSingleInstanceMutex"
handle, err := windows.CreateMutex(nil, false, windows.StringToUTF16Ptr(mutexName))
if err != nil {
return err
}
// 如果互斥体已存在,则 GetLastError 会返回 windows.ERROR_ALREADY_EXISTS
if windows.GetLastError() == windows.ERROR_ALREADY_EXISTS {
return err
}
singleInstanceMutex = handle // 保存句柄,保证不会被回收
return nil
}