Revert "Fix Fyne threading error and queue persistence issues"

This reverts commit cfb608e191.
This commit is contained in:
Stu Leak 2025-11-27 00:12:00 -05:00
parent c44074f043
commit 2552e0fcad
3 changed files with 104 additions and 378 deletions

View File

@ -51,7 +51,6 @@ type Job struct {
StartedAt *time.Time `json:"started_at,omitempty"` StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"`
Priority int `json:"priority"` // Higher priority = runs first Priority int `json:"priority"` // Higher priority = runs first
HasModifiedSettings bool `json:"has_modified_settings"`
cancel context.CancelFunc `json:"-"` cancel context.CancelFunc `json:"-"`
} }
@ -63,7 +62,6 @@ type Queue struct {
jobs []*Job jobs []*Job
executor JobExecutor executor JobExecutor
running bool running bool
paused bool // When paused, no jobs are processed even if running
mu sync.RWMutex mu sync.RWMutex
onChange func() // Callback when queue state changes onChange func() // Callback when queue state changes
} }
@ -74,7 +72,6 @@ func New(executor JobExecutor) *Queue {
jobs: make([]*Job, 0), jobs: make([]*Job, 0),
executor: executor, executor: executor,
running: false, running: false,
paused: true, // Start paused by default
} }
} }
@ -252,22 +249,6 @@ func (q *Queue) Stop() {
q.running = false 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 // processJobs continuously processes pending jobs
func (q *Queue) processJobs() { func (q *Queue) processJobs() {
for { for {
@ -277,13 +258,6 @@ func (q *Queue) processJobs() {
return 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 // Find highest priority pending job
var nextJob *Job var nextJob *Job
highestPriority := -1 highestPriority := -1
@ -404,15 +378,6 @@ func (q *Queue) Clear() {
q.notifyChange() 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 // generateID generates a unique ID for a job
func generateID() string { func generateID() string {
return fmt.Sprintf("job-%d", time.Now().UnixNano()) return fmt.Sprintf("job-%d", time.Now().UnixNano())

View File

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

219
main.go
View File

@ -153,7 +153,6 @@ func (c convertConfig) CoverLabel() string {
type appState struct { type appState struct {
window fyne.Window window fyne.Window
active string active string
initComplete bool // Track if initialization is complete
source *videoSource source *videoSource
loadedVideos []*videoSource // Multiple loaded videos for navigation loadedVideos []*videoSource // Multiple loaded videos for navigation
currentIndex int // Current video index in loadedVideos currentIndex int // Current video index in loadedVideos
@ -309,36 +308,11 @@ func (s *appState) applyInverseDefaults(src *videoSource) {
func (s *appState) setContent(body fyne.CanvasObject) { func (s *appState) setContent(body fyne.CanvasObject) {
bg := canvas.NewRectangle(backgroundColor) bg := canvas.NewRectangle(backgroundColor)
// Don't set a minimum size - let content determine layout naturally // Don't set a minimum size - let content determine layout naturally
// 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 { if body == nil {
s.window.SetContent(bg) s.window.SetContent(bg)
} else { return
}
s.window.SetContent(container.NewMax(bg, body)) 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))
}
}
}
} }
// showErrorWithCopy displays an error dialog with a "Copy Error" button // showErrorWithCopy displays an error dialog with a "Copy Error" button
@ -450,14 +424,6 @@ func (s *appState) showQueue() {
s.jobQueue.Clear() s.jobQueue.Clear()
s.showQueue() // Refresh 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 utils.MustHex("#4CE870"), // titleColor
gridColor, // bgColor gridColor, // bgColor
textColor, // textColor textColor, // textColor
@ -466,102 +432,6 @@ func (s *appState) showQueue() {
s.setContent(container.NewPadded(view)) 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 // addConvertToQueue adds a conversion job to the queue
func (s *appState) addConvertToQueue() error { func (s *appState) addConvertToQueue() error {
if s.source == nil { if s.source == nil {
@ -618,7 +488,6 @@ func (s *appState) addConvertToQueue() error {
OutputFile: outPath, OutputFile: outPath,
Config: config, Config: config,
Priority: 0, Priority: 0,
HasModifiedSettings: s.hasModifiedConvertSettings(),
} }
s.jobQueue.Add(job) s.jobQueue.Add(job)
@ -789,7 +658,6 @@ func (s *appState) batchAddToQueue(paths []string) {
OutputFile: outPath, OutputFile: outPath,
Config: config, Config: config,
Priority: 0, Priority: 0,
HasModifiedSettings: s.hasModifiedConvertSettings(),
} }
s.jobQueue.Add(job) s.jobQueue.Add(job)
@ -1094,10 +962,13 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
} }
func (s *appState) shutdown() { func (s *appState) shutdown() {
// Queue is not persisted between sessions - start fresh each time // Save queue before shutting down
if s.jobQueue != nil { if s.jobQueue != nil {
s.jobQueue.Stop() s.jobQueue.Stop()
// Don't save the queue - we want a clean slate each session 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)
}
} }
s.stopPlayer() s.stopPlayer()
@ -1166,9 +1037,6 @@ func runGUI() {
state := &appState{ state := &appState{
window: w, window: w,
source: nil, // Start with no video source
loadedVideos: nil, // Start with no videos loaded
currentIndex: 0,
convert: convertConfig{ convert: convertConfig{
OutputBase: "converted", OutputBase: "converted",
SelectedFormat: formatOptions[0], SelectedFormat: formatOptions[0],
@ -1213,11 +1081,23 @@ func runGUI() {
// Initialize job queue // Initialize job queue
state.jobQueue = queue.New(state.jobExecutor) state.jobQueue = queue.New(state.jobExecutor)
state.jobQueue.SetChangeCallback(func() {
// Update stats bar
state.updateStatsBar()
// Start with a clean queue for each session // Refresh UI when queue changes
// Queue will be populated as user adds videos via drag-and-drop if state.active == "" {
// Queue is saved to disk on shutdown but not loaded on startup state.showMainMenu()
// Start the queue but keep it paused by default - only process when user explicitly requests }
})
// 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
state.jobQueue.Start() state.jobQueue.Start()
defer state.shutdown() defer state.shutdown()
@ -1226,36 +1106,6 @@ func runGUI() {
}) })
state.showMainMenu() state.showMainMenu()
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList)) 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() w.ShowAndRun()
} }
@ -1785,31 +1635,18 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
cancelBtn.Importance = widget.DangerImportance cancelBtn.Importance = widget.DangerImportance
cancelBtn.Disable() cancelBtn.Disable()
// Queue buttons - Add and Remove // Add to Queue button
addQueueBtn := widget.NewButton("+ Add", func() { addQueueBtn := widget.NewButton("Add to Queue", func() {
if err := state.addConvertToQueue(); err != nil { if err := state.addConvertToQueue(); err != nil {
dialog.ShowError(err, state.window) dialog.ShowError(err, state.window)
} else { } else {
dialog.ShowInformation("Queue", "Job added to queue!", state.window) dialog.ShowInformation("Queue", "Job added to queue!", state.window)
} }
}) })
addQueueBtn.Importance = widget.MediumImportance
if src == nil { if src == nil {
addQueueBtn.Disable() 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() { convertBtn = widget.NewButton("CONVERT NOW", func() {
state.startConvert(statusLabel, convertBtn, cancelBtn, activity) state.startConvert(statusLabel, convertBtn, cancelBtn, activity)
}) })
@ -1821,13 +1658,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
convertBtn.Disable() convertBtn.Disable()
cancelBtn.Enable() cancelBtn.Enable()
addQueueBtn.Disable() addQueueBtn.Disable()
removeQueueBtn.Disable()
} }
// Queue management container actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, addQueueBtn, convertBtn)
queueBox := container.NewHBox(addQueueBtn, removeQueueBtn)
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, queueBox, convertBtn)
actionBar := ui.TintedBar(convertColor, actionInner) actionBar := ui.TintedBar(convertColor, actionInner)
// Wrap mainArea in a scroll container to prevent content from forcing window resize // Wrap mainArea in a scroll container to prevent content from forcing window resize