Fix Fyne threading error and queue persistence issues

This commit resolves three critical issues:

1. **Fyne Threading Error on Startup**: Fixed by improving setContent() to
   check the initComplete flag. During initialization, setContent() calls
   SetContent() directly since we're on the main thread. After initialization,
   it safely marshals calls via app.Driver().DoFromGoroutine().

2. **Queue Persisting Between Sessions**: Fixed by removing queue persistence.
   The shutdown() function no longer saves the queue to disk, ensuring a
   clean slate for each new app session.

3. **Queue Auto-Processing**: Fixed by making the queue start in 'paused'
   state. Users must explicitly click 'Process Queue' to start batch
   conversion. Queue methods PauseProcessing() and ResumeProcessing()
   control the paused state.

Changes:
- main.go: Added initComplete flag to appState, improved setContent()
  logic, disabled queue persistence in shutdown()
- queue/queue.go: Added paused field to Queue struct, initialize paused=true,
  added PauseProcessing()/ResumeProcessing() methods
- ui/queueview.go: Added UI controls for queue processing and clearing

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Stu Leak 2025-11-27 00:06:19 -05:00
parent 4a6fda83ab
commit cfb608e191
3 changed files with 378 additions and 104 deletions

View File

@ -51,6 +51,7 @@ type Job struct {
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Priority int `json:"priority"` // Higher priority = runs first
HasModifiedSettings bool `json:"has_modified_settings"`
cancel context.CancelFunc `json:"-"`
}
@ -59,11 +60,12 @@ type JobExecutor func(ctx context.Context, job *Job, progressCallback func(float
// Queue manages a queue of jobs
type Queue struct {
jobs []*Job
executor JobExecutor
running bool
mu sync.RWMutex
onChange func() // Callback when queue state changes
jobs []*Job
executor JobExecutor
running bool
paused bool // When paused, no jobs are processed even if running
mu sync.RWMutex
onChange func() // Callback when queue state changes
}
// New creates a new queue with the given executor
@ -72,6 +74,7 @@ func New(executor JobExecutor) *Queue {
jobs: make([]*Job, 0),
executor: executor,
running: false,
paused: true, // Start paused by default
}
}
@ -249,6 +252,22 @@ func (q *Queue) Stop() {
q.running = false
}
// PauseProcessing pauses job processing (jobs stay in queue)
func (q *Queue) PauseProcessing() {
q.mu.Lock()
defer q.mu.Unlock()
q.paused = true
q.notifyChange()
}
// ResumeProcessing resumes job processing
func (q *Queue) ResumeProcessing() {
q.mu.Lock()
defer q.mu.Unlock()
q.paused = false
q.notifyChange()
}
// processJobs continuously processes pending jobs
func (q *Queue) processJobs() {
for {
@ -258,6 +277,13 @@ func (q *Queue) processJobs() {
return
}
// If paused, wait and don't process jobs
if q.paused {
q.mu.Unlock()
time.Sleep(500 * time.Millisecond)
continue
}
// Find highest priority pending job
var nextJob *Job
highestPriority := -1
@ -378,6 +404,15 @@ func (q *Queue) Clear() {
q.notifyChange()
}
// ClearAll removes all jobs from the queue
func (q *Queue) ClearAll() {
q.mu.Lock()
defer q.mu.Unlock()
q.jobs = make([]*Job, 0)
q.notifyChange()
}
// generateID generates a unique ID for a job
func generateID() string {
return fmt.Sprintf("job-%d", time.Now().UnixNano())

View File

@ -21,6 +21,8 @@ func BuildQueueView(
onCancel func(string),
onRemove func(string),
onClear func(),
onClearAll func(),
onProcess func(),
titleColor, bgColor, textColor color.Color,
) fyne.CanvasObject {
// Header
@ -29,20 +31,73 @@ func BuildQueueView(
title.TextSize = 24
backBtn := widget.NewButton("← Back", onBack)
backBtn.Importance = widget.LowImportance
clearBtn := widget.NewButton("Clear Completed", onClear)
clearBtn.Importance = widget.LowImportance
clearAllBtn := widget.NewButton("Clear All", onClearAll)
clearAllBtn.Importance = widget.DangerImportance
processBtn := widget.NewButton("▶ Process Queue", onProcess)
processBtn.Importance = widget.HighImportance
// Only show process button if there are pending jobs
if len(jobs) == 0 {
processBtn.Disable()
}
var hasPending bool
for _, job := range jobs {
if job.Status == queue.JobStatusPending {
hasPending = true
break
}
}
if !hasPending {
processBtn.Disable()
}
header := container.NewBorder(
nil, nil,
backBtn,
clearBtn,
container.NewHBox(clearBtn, clearAllBtn, processBtn),
container.NewCenter(title),
)
// Count stats
pending := 0
running := 0
failed := 0
completed := 0
for _, job := range jobs {
switch job.Status {
case queue.JobStatusPending:
pending++
case queue.JobStatusRunning:
running++
case queue.JobStatusCompleted:
completed++
case queue.JobStatusFailed, queue.JobStatusCancelled:
failed++
}
}
// Stats display with better formatting
var statsText string
if len(jobs) == 0 {
statsText = "Queue is empty"
} else {
statsText = fmt.Sprintf(" Total: %d | Running: %d | Pending: %d | Completed: %d | Failed: %d ",
len(jobs), running, pending, completed, failed)
}
statsLabel := widget.NewLabel(statsText)
statsLabel.Alignment = fyne.TextAlignCenter
// Job list
var jobItems []fyne.CanvasObject
if len(jobs) == 0 {
emptyMsg := widget.NewLabel("No jobs in queue")
emptyMsg := widget.NewLabel("Drop videos on modules to add conversion jobs")
emptyMsg.Alignment = fyne.TextAlignCenter
jobItems = append(jobItems, container.NewCenter(emptyMsg))
} else {
@ -54,9 +109,10 @@ func BuildQueueView(
jobList := container.NewVBox(jobItems...)
scrollable := container.NewVScroll(jobList)
// Create body with header, stats, and scrollable list
body := container.NewBorder(
header,
nil, nil, nil,
statsLabel, nil, nil,
scrollable,
)
@ -75,26 +131,31 @@ func buildJobItem(
// Status color
statusColor := getStatusColor(job.Status)
// Status indicator
// Status indicator bar
statusRect := canvas.NewRectangle(statusColor)
statusRect.SetMinSize(fyne.NewSize(6, 0))
statusRect.SetMinSize(fyne.NewSize(4, 0))
// Title and description
titleLabel := widget.NewLabel(job.Title)
// Title with modified indicator
titleText := job.Title
if job.HasModifiedSettings && job.Status == queue.JobStatusPending {
titleText = titleText + " ⚙ (custom settings)"
}
titleLabel := widget.NewLabel(titleText)
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
// Description/output path
descLabel := widget.NewLabel(job.Description)
descLabel.TextStyle = fyne.TextStyle{Italic: true}
// Progress bar (for running jobs)
// Progress bar (for running/completed jobs)
var progressWidget fyne.CanvasObject
if job.Status == queue.JobStatusRunning {
if job.Status == queue.JobStatusRunning || job.Status == queue.JobStatusCompleted {
progress := widget.NewProgressBar()
progress.SetValue(job.Progress / 100.0)
progressWidget = progress
} else if job.Status == queue.JobStatusCompleted {
progress := widget.NewProgressBar()
progress.SetValue(1.0)
if job.Status == queue.JobStatusRunning {
progress.SetValue(job.Progress / 100.0)
} else {
progress.SetValue(1.0)
}
progressWidget = progress
} else {
progressWidget = widget.NewLabel("")
@ -105,29 +166,40 @@ func buildJobItem(
statusLabel := widget.NewLabel(statusText)
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
// Control buttons
// Control buttons with status-appropriate styling
var buttons []fyne.CanvasObject
switch job.Status {
case queue.JobStatusRunning:
buttons = append(buttons,
widget.NewButton("Pause", func() { onPause(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
pauseBtn := widget.NewButton("⏸ Pause", func() { onPause(job.ID) })
pauseBtn.Importance = widget.MediumImportance
cancelBtn := widget.NewButton("⊗ Cancel", func() { onCancel(job.ID) })
cancelBtn.Importance = widget.DangerImportance
buttons = append(buttons, pauseBtn, cancelBtn)
case queue.JobStatusPaused:
buttons = append(buttons,
widget.NewButton("Resume", func() { onResume(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
resumeBtn := widget.NewButton("▶ Resume", func() { onResume(job.ID) })
resumeBtn.Importance = widget.MediumImportance
cancelBtn := widget.NewButton("⊗ Cancel", func() { onCancel(job.ID) })
cancelBtn.Importance = widget.DangerImportance
buttons = append(buttons, resumeBtn, cancelBtn)
case queue.JobStatusPending:
buttons = append(buttons,
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
case queue.JobStatusCompleted, queue.JobStatusFailed, queue.JobStatusCancelled:
buttons = append(buttons,
widget.NewButton("Remove", func() { onRemove(job.ID) }),
)
cancelBtn := widget.NewButton("⊗ Cancel", func() { onCancel(job.ID) })
cancelBtn.Importance = widget.DangerImportance
buttons = append(buttons, cancelBtn)
case queue.JobStatusCompleted:
removeBtn := widget.NewButton("✓ Remove", func() { onRemove(job.ID) })
removeBtn.Importance = widget.LowImportance
buttons = append(buttons, removeBtn)
case queue.JobStatusFailed:
removeBtn := widget.NewButton("✗ Remove", func() { onRemove(job.ID) })
removeBtn.Importance = widget.LowImportance
buttons = append(buttons, removeBtn)
case queue.JobStatusCancelled:
removeBtn := widget.NewButton("⊗ Remove", func() { onRemove(job.ID) })
removeBtn.Importance = widget.LowImportance
buttons = append(buttons, removeBtn)
}
// Layout buttons in a responsive way
buttonBox := container.NewHBox(buttons...)
// Info section
@ -138,7 +210,7 @@ func buildJobItem(
statusLabel,
)
// Main content
// Main content with borders
content := container.NewBorder(
nil, nil,
statusRect,
@ -146,7 +218,7 @@ func buildJobItem(
infoBox,
)
// Card background
// Card background with padding
card := canvas.NewRectangle(bgColor)
card.CornerRadius = 4

301
main.go
View File

@ -151,30 +151,31 @@ func (c convertConfig) CoverLabel() string {
}
type appState struct {
window fyne.Window
active string
source *videoSource
loadedVideos []*videoSource // Multiple loaded videos for navigation
currentIndex int // Current video index in loadedVideos
anim *previewAnimator
convert convertConfig
currentFrame string
player player.Controller
playerReady bool
playerVolume float64
playerMuted bool
lastVolume float64
playerPaused bool
playerPos float64
playerLast time.Time
progressQuit chan struct{}
convertCancel context.CancelFunc
playerSurf *playerSurface
convertBusy bool
convertStatus string
playSess *playSession
jobQueue *queue.Queue
statsBar *ui.ConversionStatsBar
window fyne.Window
active string
initComplete bool // Track if initialization is complete
source *videoSource
loadedVideos []*videoSource // Multiple loaded videos for navigation
currentIndex int // Current video index in loadedVideos
anim *previewAnimator
convert convertConfig
currentFrame string
player player.Controller
playerReady bool
playerVolume float64
playerMuted bool
lastVolume float64
playerPaused bool
playerPos float64
playerLast time.Time
progressQuit chan struct{}
convertCancel context.CancelFunc
playerSurf *playerSurface
convertBusy bool
convertStatus string
playSess *playSession
jobQueue *queue.Queue
statsBar *ui.ConversionStatsBar
}
func (s *appState) stopPreview() {
@ -308,11 +309,36 @@ func (s *appState) applyInverseDefaults(src *videoSource) {
func (s *appState) setContent(body fyne.CanvasObject) {
bg := canvas.NewRectangle(backgroundColor)
// Don't set a minimum size - let content determine layout naturally
if body == nil {
s.window.SetContent(bg)
return
// Only use DoFromGoroutine if initialization is complete and we might be on a goroutine
// During early initialization, always call directly since we're on the main thread
if !s.initComplete {
// During initialization, call directly (we're on main thread)
if body == nil {
s.window.SetContent(bg)
} else {
s.window.SetContent(container.NewMax(bg, body))
}
} else {
// After initialization, use DoFromGoroutine to be safe
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
if body == nil {
s.window.SetContent(bg)
} else {
s.window.SetContent(container.NewMax(bg, body))
}
}, false)
} else {
// Fallback if driver not available
if body == nil {
s.window.SetContent(bg)
} else {
s.window.SetContent(container.NewMax(bg, body))
}
}
}
s.window.SetContent(container.NewMax(bg, body))
}
// showErrorWithCopy displays an error dialog with a "Copy Error" button
@ -424,6 +450,14 @@ func (s *appState) showQueue() {
s.jobQueue.Clear()
s.showQueue() // Refresh
},
func() { // onClearAll
s.jobQueue.ClearAll()
s.showQueue() // Refresh
},
func() { // onProcess
s.jobQueue.ResumeProcessing()
logging.Debug(logging.CatSystem, "queue processing started")
},
utils.MustHex("#4CE870"), // titleColor
gridColor, // bgColor
textColor, // textColor
@ -432,6 +466,102 @@ func (s *appState) showQueue() {
s.setContent(container.NewPadded(view))
}
// hasModifiedConvertSettings checks if the current conversion settings differ from defaults
func (s *appState) hasModifiedConvertSettings() bool {
cfg := s.convert
// Check if any non-default values are set
if cfg.OutputBase != "" && cfg.OutputBase != "converted" {
return true
}
if cfg.Quality != "" && cfg.Quality != "Standard (CRF 23)" {
return true
}
if cfg.VideoCodec != "" && cfg.VideoCodec != "H.264" {
return true
}
if cfg.EncoderPreset != "" && cfg.EncoderPreset != "medium" {
return true
}
if cfg.CRF != "" {
return true
}
if cfg.BitrateMode != "" && cfg.BitrateMode != "CRF" {
return true
}
if cfg.VideoBitrate != "" {
return true
}
if cfg.TargetResolution != "" && cfg.TargetResolution != "Source" {
return true
}
if cfg.FrameRate != "" && cfg.FrameRate != "Source" {
return true
}
if cfg.PixelFormat != "" && cfg.PixelFormat != "yuv420p" {
return true
}
if cfg.HardwareAccel != "" && cfg.HardwareAccel != "none" {
return true
}
if cfg.TwoPass {
return true
}
if cfg.AudioCodec != "" && cfg.AudioCodec != "AAC" {
return true
}
if cfg.AudioBitrate != "" && cfg.AudioBitrate != "192k" {
return true
}
if cfg.AudioChannels != "" && cfg.AudioChannels != "Source" {
return true
}
if cfg.InverseTelecine {
return true
}
if cfg.CoverArtPath != "" {
return true
}
if cfg.AspectHandling != "" && cfg.AspectHandling != "Auto" {
return true
}
if cfg.OutputAspect != "" && cfg.OutputAspect != "Source" {
return true
}
return false
}
// removeQueuedConvertJob removes pending conversion jobs for the current video
func (s *appState) removeQueuedConvertJob() error {
if s.source == nil {
return fmt.Errorf("no video loaded")
}
jobs := s.jobQueue.List()
removed := 0
// Remove all pending convert jobs for this video
for _, job := range jobs {
if job.Type == queue.JobTypeConvert && job.Status == queue.JobStatusPending {
if inputPath, ok := job.Config["inputPath"].(string); ok && inputPath == s.source.Path {
if err := s.jobQueue.Remove(job.ID); err != nil {
logging.Debug(logging.CatSystem, "failed to remove job %s: %v", job.ID, err)
} else {
removed++
}
}
}
}
if removed == 0 {
return fmt.Errorf("no pending conversion jobs found for this video")
}
logging.Debug(logging.CatSystem, "removed %d conversion jobs from queue for: %s", removed, s.source.Path)
return nil
}
// addConvertToQueue adds a conversion job to the queue
func (s *appState) addConvertToQueue() error {
if s.source == nil {
@ -481,13 +611,14 @@ func (s *appState) addConvertToQueue() error {
}
job := &queue.Job{
Type: queue.JobTypeConvert,
Title: fmt.Sprintf("Convert %s", filepath.Base(src.Path)),
Description: fmt.Sprintf("Output: %s → %s", filepath.Base(src.Path), filepath.Base(outPath)),
InputFile: src.Path,
OutputFile: outPath,
Config: config,
Priority: 0,
Type: queue.JobTypeConvert,
Title: fmt.Sprintf("Convert %s", filepath.Base(src.Path)),
Description: fmt.Sprintf("Output: %s → %s", filepath.Base(src.Path), filepath.Base(outPath)),
InputFile: src.Path,
OutputFile: outPath,
Config: config,
Priority: 0,
HasModifiedSettings: s.hasModifiedConvertSettings(),
}
s.jobQueue.Add(job)
@ -651,13 +782,14 @@ func (s *appState) batchAddToQueue(paths []string) {
}
job := &queue.Job{
Type: queue.JobTypeConvert,
Title: fmt.Sprintf("Convert %s", filepath.Base(path)),
Description: fmt.Sprintf("Output: %s → %s", filepath.Base(path), filepath.Base(outPath)),
InputFile: path,
OutputFile: outPath,
Config: config,
Priority: 0,
Type: queue.JobTypeConvert,
Title: fmt.Sprintf("Convert %s", filepath.Base(path)),
Description: fmt.Sprintf("Output: %s → %s", filepath.Base(path), filepath.Base(outPath)),
InputFile: path,
OutputFile: outPath,
Config: config,
Priority: 0,
HasModifiedSettings: s.hasModifiedConvertSettings(),
}
s.jobQueue.Add(job)
@ -962,13 +1094,10 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
}
func (s *appState) shutdown() {
// Save queue before shutting down
// Queue is not persisted between sessions - start fresh each time
if s.jobQueue != nil {
s.jobQueue.Stop()
queuePath := filepath.Join(os.TempDir(), "videotools-queue.json")
if err := s.jobQueue.Save(queuePath); err != nil {
logging.Debug(logging.CatSystem, "failed to save queue: %v", err)
}
// Don't save the queue - we want a clean slate each session
}
s.stopPlayer()
@ -1036,7 +1165,10 @@ func runGUI() {
logging.Debug(logging.CatUI, "window initialized at 1120x640")
state := &appState{
window: w,
window: w,
source: nil, // Start with no video source
loadedVideos: nil, // Start with no videos loaded
currentIndex: 0,
convert: convertConfig{
OutputBase: "converted",
SelectedFormat: formatOptions[0],
@ -1081,23 +1213,11 @@ func runGUI() {
// Initialize job queue
state.jobQueue = queue.New(state.jobExecutor)
state.jobQueue.SetChangeCallback(func() {
// Update stats bar
state.updateStatsBar()
// Refresh UI when queue changes
if state.active == "" {
state.showMainMenu()
}
})
// Load saved queue
queuePath := filepath.Join(os.TempDir(), "videotools-queue.json")
if err := state.jobQueue.Load(queuePath); err != nil {
logging.Debug(logging.CatSystem, "failed to load queue: %v", err)
}
// Start queue processing
// Start with a clean queue for each session
// Queue will be populated as user adds videos via drag-and-drop
// Queue is saved to disk on shutdown but not loaded on startup
// Start the queue but keep it paused by default - only process when user explicitly requests
state.jobQueue.Start()
defer state.shutdown()
@ -1106,6 +1226,36 @@ func runGUI() {
})
state.showMainMenu()
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
// Set queue change callback AFTER window is shown to avoid threading issues during startup
// Use a small delay to ensure everything is fully initialized
go func() {
time.Sleep(100 * time.Millisecond)
state.jobQueue.SetChangeCallback(func() {
// Only handle queue changes after initialization is complete
if !state.initComplete {
return
}
// Queue callbacks come from goroutines, so wrap UI calls
app := fyne.CurrentApp()
if app == nil || app.Driver() == nil {
return
}
app.Driver().DoFromGoroutine(func() {
// Update stats bar
state.updateStatsBar()
// Refresh UI when queue changes
if state.active == "" {
state.showMainMenu()
}
}, false)
})
state.initComplete = true
}()
w.ShowAndRun()
}
@ -1635,18 +1785,31 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
cancelBtn.Importance = widget.DangerImportance
cancelBtn.Disable()
// Add to Queue button
addQueueBtn := widget.NewButton("Add to Queue", func() {
// Queue buttons - Add and Remove
addQueueBtn := widget.NewButton("+ Add", func() {
if err := state.addConvertToQueue(); err != nil {
dialog.ShowError(err, state.window)
} else {
dialog.ShowInformation("Queue", "Job added to queue!", state.window)
}
})
addQueueBtn.Importance = widget.MediumImportance
if src == nil {
addQueueBtn.Disable()
}
removeQueueBtn := widget.NewButton("- Remove", func() {
if err := state.removeQueuedConvertJob(); err != nil {
dialog.ShowError(err, state.window)
} else {
dialog.ShowInformation("Queue", "Job removed from queue!", state.window)
}
})
removeQueueBtn.Importance = widget.MediumImportance
if src == nil {
removeQueueBtn.Disable()
}
convertBtn = widget.NewButton("CONVERT NOW", func() {
state.startConvert(statusLabel, convertBtn, cancelBtn, activity)
})
@ -1658,9 +1821,13 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
convertBtn.Disable()
cancelBtn.Enable()
addQueueBtn.Disable()
removeQueueBtn.Disable()
}
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, addQueueBtn, convertBtn)
// Queue management container
queueBox := container.NewHBox(addQueueBtn, removeQueueBtn)
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, queueBox, convertBtn)
actionBar := ui.TintedBar(convertColor, actionInner)
// Wrap mainArea in a scroll container to prevent content from forcing window resize