Administrator
发布于 2025-04-15 / 8 阅读
0
0

【golang】定时音乐播放器

// 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 = "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"
		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
}


评论