diff --git a/internal/queue/queue.go b/internal/queue/queue.go index dcfcd85..1ea462d 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -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()) diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go index 634fb8a..f019ed5 100644 --- a/internal/ui/queueview.go +++ b/internal/ui/queueview.go @@ -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 diff --git a/main.go b/main.go index 8a7857e..351d7e5 100644 --- a/main.go +++ b/main.go @@ -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