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