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 cfb608e191
commit 813c0fd17d
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"`
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:"-"`
}
@ -60,12 +59,11 @@ 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
paused bool // When paused, no jobs are processed even if running
mu sync.RWMutex
onChange func() // Callback when queue state changes
jobs []*Job
executor JobExecutor
running bool
mu sync.RWMutex
onChange func() // Callback when queue state changes
}
// New creates a new queue with the given executor
@ -74,7 +72,6 @@ func New(executor JobExecutor) *Queue {
jobs: make([]*Job, 0),
executor: executor,
running: false,
paused: true, // Start paused by default
}
}
@ -252,22 +249,6 @@ 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 {
@ -277,13 +258,6 @@ 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
@ -404,15 +378,6 @@ 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,8 +21,6 @@ func BuildQueueView(
onCancel func(string),
onRemove func(string),
onClear func(),
onClearAll func(),
onProcess func(),
titleColor, bgColor, textColor color.Color,
) fyne.CanvasObject {
// Header
@ -31,73 +29,20 @@ 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,
container.NewHBox(clearBtn, clearAllBtn, processBtn),
clearBtn,
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("Drop videos on modules to add conversion jobs")
emptyMsg := widget.NewLabel("No jobs in queue")
emptyMsg.Alignment = fyne.TextAlignCenter
jobItems = append(jobItems, container.NewCenter(emptyMsg))
} else {
@ -109,10 +54,9 @@ func BuildQueueView(
jobList := container.NewVBox(jobItems...)
scrollable := container.NewVScroll(jobList)
// Create body with header, stats, and scrollable list
body := container.NewBorder(
header,
statsLabel, nil, nil,
nil, nil, nil,
scrollable,
)
@ -131,31 +75,26 @@ func buildJobItem(
// Status color
statusColor := getStatusColor(job.Status)
// Status indicator bar
// Status indicator
statusRect := canvas.NewRectangle(statusColor)
statusRect.SetMinSize(fyne.NewSize(4, 0))
statusRect.SetMinSize(fyne.NewSize(6, 0))
// Title with modified indicator
titleText := job.Title
if job.HasModifiedSettings && job.Status == queue.JobStatusPending {
titleText = titleText + " ⚙ (custom settings)"
}
titleLabel := widget.NewLabel(titleText)
// Title and description
titleLabel := widget.NewLabel(job.Title)
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
// Description/output path
descLabel := widget.NewLabel(job.Description)
descLabel.TextStyle = fyne.TextStyle{Italic: true}
// Progress bar (for running/completed jobs)
// Progress bar (for running jobs)
var progressWidget fyne.CanvasObject
if job.Status == queue.JobStatusRunning || job.Status == queue.JobStatusCompleted {
if job.Status == queue.JobStatusRunning {
progress := widget.NewProgressBar()
if job.Status == queue.JobStatusRunning {
progress.SetValue(job.Progress / 100.0)
} else {
progress.SetValue(1.0)
}
progress.SetValue(job.Progress / 100.0)
progressWidget = progress
} else if job.Status == queue.JobStatusCompleted {
progress := widget.NewProgressBar()
progress.SetValue(1.0)
progressWidget = progress
} else {
progressWidget = widget.NewLabel("")
@ -166,40 +105,29 @@ func buildJobItem(
statusLabel := widget.NewLabel(statusText)
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
// Control buttons with status-appropriate styling
// Control buttons
var buttons []fyne.CanvasObject
switch job.Status {
case queue.JobStatusRunning:
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)
buttons = append(buttons,
widget.NewButton("Pause", func() { onPause(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
case queue.JobStatusPaused:
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)
buttons = append(buttons,
widget.NewButton("Resume", func() { onResume(job.ID) }),
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
)
case queue.JobStatusPending:
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)
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) }),
)
}
// Layout buttons in a responsive way
buttonBox := container.NewHBox(buttons...)
// Info section
@ -210,7 +138,7 @@ func buildJobItem(
statusLabel,
)
// Main content with borders
// Main content
content := container.NewBorder(
nil, nil,
statusRect,
@ -218,7 +146,7 @@ func buildJobItem(
infoBox,
)
// Card background with padding
// Card background
card := canvas.NewRectangle(bgColor)
card.CornerRadius = 4

301
main.go
View File

@ -151,31 +151,30 @@ func (c convertConfig) CoverLabel() string {
}
type appState struct {
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
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
}
func (s *appState) stopPreview() {
@ -309,36 +308,11 @@ 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
// 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))
}
}
if body == nil {
s.window.SetContent(bg)
return
}
s.window.SetContent(container.NewMax(bg, body))
}
// showErrorWithCopy displays an error dialog with a "Copy Error" button
@ -450,14 +424,6 @@ 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
@ -466,102 +432,6 @@ 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 {
@ -611,14 +481,13 @@ 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,
HasModifiedSettings: s.hasModifiedConvertSettings(),
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,
}
s.jobQueue.Add(job)
@ -782,14 +651,13 @@ 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,
HasModifiedSettings: s.hasModifiedConvertSettings(),
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,
}
s.jobQueue.Add(job)
@ -1094,10 +962,13 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
}
func (s *appState) shutdown() {
// Queue is not persisted between sessions - start fresh each time
// Save queue before shutting down
if s.jobQueue != nil {
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()
@ -1165,10 +1036,7 @@ func runGUI() {
logging.Debug(logging.CatUI, "window initialized at 1120x640")
state := &appState{
window: w,
source: nil, // Start with no video source
loadedVideos: nil, // Start with no videos loaded
currentIndex: 0,
window: w,
convert: convertConfig{
OutputBase: "converted",
SelectedFormat: formatOptions[0],
@ -1213,11 +1081,23 @@ func runGUI() {
// Initialize job queue
state.jobQueue = queue.New(state.jobExecutor)
state.jobQueue.SetChangeCallback(func() {
// Update stats bar
state.updateStatsBar()
// 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
// 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
state.jobQueue.Start()
defer state.shutdown()
@ -1226,36 +1106,6 @@ 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()
}
@ -1785,31 +1635,18 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
cancelBtn.Importance = widget.DangerImportance
cancelBtn.Disable()
// Queue buttons - Add and Remove
addQueueBtn := widget.NewButton("+ Add", func() {
// Add to Queue button
addQueueBtn := widget.NewButton("Add to Queue", 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)
})
@ -1821,13 +1658,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
convertBtn.Disable()
cancelBtn.Enable()
addQueueBtn.Disable()
removeQueueBtn.Disable()
}
// Queue management container
queueBox := container.NewHBox(addQueueBtn, removeQueueBtn)
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, queueBox, convertBtn)
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, addQueueBtn, convertBtn)
actionBar := ui.TintedBar(convertColor, actionInner)
// Wrap mainArea in a scroll container to prevent content from forcing window resize