diff --git a/internal/queue/queue.go b/internal/queue/queue.go new file mode 100644 index 0000000..b776e0c --- /dev/null +++ b/internal/queue/queue.go @@ -0,0 +1,382 @@ +package queue + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// JobType represents the type of job to execute +type JobType string + +const ( + JobTypeConvert JobType = "convert" + JobTypeMerge JobType = "merge" + JobTypeTrim JobType = "trim" + JobTypeFilter JobType = "filter" + JobTypeUpscale JobType = "upscale" + JobTypeAudio JobType = "audio" + JobTypeThumb JobType = "thumb" +) + +// JobStatus represents the current state of a job +type JobStatus string + +const ( + JobStatusPending JobStatus = "pending" + JobStatusRunning JobStatus = "running" + JobStatusPaused JobStatus = "paused" + JobStatusCompleted JobStatus = "completed" + JobStatusFailed JobStatus = "failed" + JobStatusCancelled JobStatus = "cancelled" +) + +// Job represents a single job in the queue +type Job struct { + ID string `json:"id"` + Type JobType `json:"type"` + Status JobStatus `json:"status"` + Title string `json:"title"` + Description string `json:"description"` + InputFile string `json:"input_file"` + OutputFile string `json:"output_file"` + Config map[string]interface{} `json:"config"` + Progress float64 `json:"progress"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Priority int `json:"priority"` // Higher priority = runs first + cancel context.CancelFunc `json:"-"` +} + +// JobExecutor is a function that executes a job +type JobExecutor func(ctx context.Context, job *Job, progressCallback func(float64)) error + +// 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 +} + +// New creates a new queue with the given executor +func New(executor JobExecutor) *Queue { + return &Queue{ + jobs: make([]*Job, 0), + executor: executor, + running: false, + } +} + +// SetChangeCallback sets a callback to be called when the queue state changes +func (q *Queue) SetChangeCallback(callback func()) { + q.mu.Lock() + defer q.mu.Unlock() + q.onChange = callback +} + +// notifyChange triggers the onChange callback if set +func (q *Queue) notifyChange() { + if q.onChange != nil { + q.onChange() + } +} + +// Add adds a job to the queue +func (q *Queue) Add(job *Job) { + q.mu.Lock() + defer q.mu.Unlock() + + if job.ID == "" { + job.ID = generateID() + } + if job.CreatedAt.IsZero() { + job.CreatedAt = time.Now() + } + if job.Status == "" { + job.Status = JobStatusPending + } + + q.jobs = append(q.jobs, job) + q.notifyChange() +} + +// Remove removes a job from the queue by ID +func (q *Queue) Remove(id string) error { + q.mu.Lock() + defer q.mu.Unlock() + + for i, job := range q.jobs { + if job.ID == id { + // Cancel if running + if job.Status == JobStatusRunning && job.cancel != nil { + job.cancel() + } + q.jobs = append(q.jobs[:i], q.jobs[i+1:]...) + q.notifyChange() + return nil + } + } + return fmt.Errorf("job not found: %s", id) +} + +// Get retrieves a job by ID +func (q *Queue) Get(id string) (*Job, error) { + q.mu.RLock() + defer q.mu.RUnlock() + + for _, job := range q.jobs { + if job.ID == id { + return job, nil + } + } + return nil, fmt.Errorf("job not found: %s", id) +} + +// List returns all jobs in the queue +func (q *Queue) List() []*Job { + q.mu.RLock() + defer q.mu.RUnlock() + + result := make([]*Job, len(q.jobs)) + copy(result, q.jobs) + return result +} + +// Stats returns queue statistics +func (q *Queue) Stats() (pending, running, completed, failed int) { + q.mu.RLock() + defer q.mu.RUnlock() + + for _, job := range q.jobs { + switch job.Status { + case JobStatusPending, JobStatusPaused: + pending++ + case JobStatusRunning: + running++ + case JobStatusCompleted: + completed++ + case JobStatusFailed, JobStatusCancelled: + failed++ + } + } + return +} + +// Pause pauses a running job +func (q *Queue) Pause(id string) error { + q.mu.Lock() + defer q.mu.Unlock() + + for _, job := range q.jobs { + if job.ID == id { + if job.Status != JobStatusRunning { + return fmt.Errorf("job is not running") + } + if job.cancel != nil { + job.cancel() + } + job.Status = JobStatusPaused + q.notifyChange() + return nil + } + } + return fmt.Errorf("job not found: %s", id) +} + +// Resume resumes a paused job +func (q *Queue) Resume(id string) error { + q.mu.Lock() + defer q.mu.Unlock() + + for _, job := range q.jobs { + if job.ID == id { + if job.Status != JobStatusPaused { + return fmt.Errorf("job is not paused") + } + job.Status = JobStatusPending + q.notifyChange() + return nil + } + } + return fmt.Errorf("job not found: %s", id) +} + +// Cancel cancels a job +func (q *Queue) Cancel(id string) error { + q.mu.Lock() + defer q.mu.Unlock() + + for _, job := range q.jobs { + if job.ID == id { + if job.Status == JobStatusRunning && job.cancel != nil { + job.cancel() + } + job.Status = JobStatusCancelled + q.notifyChange() + return nil + } + } + return fmt.Errorf("job not found: %s", id) +} + +// Start starts processing jobs in the queue +func (q *Queue) Start() { + q.mu.Lock() + if q.running { + q.mu.Unlock() + return + } + q.running = true + q.mu.Unlock() + + go q.processJobs() +} + +// Stop stops processing jobs +func (q *Queue) Stop() { + q.mu.Lock() + defer q.mu.Unlock() + q.running = false +} + +// processJobs continuously processes pending jobs +func (q *Queue) processJobs() { + for { + q.mu.Lock() + if !q.running { + q.mu.Unlock() + return + } + + // Find highest priority pending job + var nextJob *Job + highestPriority := -1 + for _, job := range q.jobs { + if job.Status == JobStatusPending && job.Priority > highestPriority { + nextJob = job + highestPriority = job.Priority + } + } + + if nextJob == nil { + q.mu.Unlock() + time.Sleep(500 * time.Millisecond) + continue + } + + // Mark as running + nextJob.Status = JobStatusRunning + now := time.Now() + nextJob.StartedAt = &now + ctx, cancel := context.WithCancel(context.Background()) + nextJob.cancel = cancel + + q.mu.Unlock() + q.notifyChange() + + // Execute job + err := q.executor(ctx, nextJob, func(progress float64) { + q.mu.Lock() + nextJob.Progress = progress + q.mu.Unlock() + q.notifyChange() + }) + + // Update job status + q.mu.Lock() + now = time.Now() + nextJob.CompletedAt = &now + if err != nil { + nextJob.Status = JobStatusFailed + nextJob.Error = err.Error() + } else { + nextJob.Status = JobStatusCompleted + nextJob.Progress = 100.0 + } + nextJob.cancel = nil + q.mu.Unlock() + q.notifyChange() + } +} + +// Save saves the queue to a JSON file +func (q *Queue) Save(path string) error { + q.mu.RLock() + defer q.mu.RUnlock() + + // Create directory if it doesn't exist + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + data, err := json.MarshalIndent(q.jobs, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal queue: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write queue file: %w", err) + } + + return nil +} + +// Load loads the queue from a JSON file +func (q *Queue) Load(path string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // No saved queue, that's OK + } + return fmt.Errorf("failed to read queue file: %w", err) + } + + var jobs []*Job + if err := json.Unmarshal(data, &jobs); err != nil { + return fmt.Errorf("failed to unmarshal queue: %w", err) + } + + q.mu.Lock() + defer q.mu.Unlock() + + // Reset running jobs to pending + for _, job := range jobs { + if job.Status == JobStatusRunning { + job.Status = JobStatusPending + job.Progress = 0 + } + } + + q.jobs = jobs + q.notifyChange() + return nil +} + +// Clear removes all completed, failed, and cancelled jobs +func (q *Queue) Clear() { + q.mu.Lock() + defer q.mu.Unlock() + + filtered := make([]*Job, 0) + for _, job := range q.jobs { + if job.Status == JobStatusPending || job.Status == JobStatusRunning || job.Status == JobStatusPaused { + filtered = append(filtered, job) + } + } + q.jobs = filtered + 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/components.go b/internal/ui/components.go index c887be0..2a9ad29 100644 --- a/internal/ui/components.go +++ b/internal/ui/components.go @@ -163,6 +163,61 @@ func TintedBar(col color.Color, body fyne.CanvasObject) fyne.CanvasObject { return container.NewMax(rect, padded) } +// Tappable wraps any canvas object and makes it tappable +type Tappable struct { + widget.BaseWidget + content fyne.CanvasObject + onTapped func() +} + +// NewTappable creates a new tappable wrapper +func NewTappable(content fyne.CanvasObject, onTapped func()) *Tappable { + t := &Tappable{ + content: content, + onTapped: onTapped, + } + t.ExtendBaseWidget(t) + return t +} + +// CreateRenderer creates the renderer for the tappable +func (t *Tappable) CreateRenderer() fyne.WidgetRenderer { + return &tappableRenderer{ + tappable: t, + content: t.content, + } +} + +// Tapped handles tap events +func (t *Tappable) Tapped(*fyne.PointEvent) { + if t.onTapped != nil { + t.onTapped() + } +} + +type tappableRenderer struct { + tappable *Tappable + content fyne.CanvasObject +} + +func (r *tappableRenderer) Layout(size fyne.Size) { + r.content.Resize(size) +} + +func (r *tappableRenderer) MinSize() fyne.Size { + return r.content.MinSize() +} + +func (r *tappableRenderer) Refresh() { + r.content.Refresh() +} + +func (r *tappableRenderer) Destroy() {} + +func (r *tappableRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.content} +} + // DraggableVScroll creates a vertical scroll container with draggable track type DraggableVScroll struct { widget.BaseWidget diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index d0b7926..d81c823 100644 --- a/internal/ui/mainmenu.go +++ b/internal/ui/mainmenu.go @@ -20,12 +20,12 @@ type ModuleInfo struct { } // BuildMainMenu creates the main menu view with module tiles -func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), titleColor, queueColor, textColor color.Color) fyne.CanvasObject { +func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int) fyne.CanvasObject { title := canvas.NewText("VIDEOTOOLS", titleColor) title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} title.TextSize = 28 - queueTile := buildQueueTile(0, 0, queueColor, textColor) + queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) header := container.New(layout.NewHBoxLayout(), title, @@ -70,7 +70,7 @@ func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fy } // buildQueueTile creates the queue status tile -func buildQueueTile(done, total int, queueColor, textColor color.Color) fyne.CanvasObject { +func buildQueueTile(done, total int, queueColor, textColor color.Color, onClick func()) fyne.CanvasObject { rect := canvas.NewRectangle(queueColor) rect.CornerRadius = 8 rect.SetMinSize(fyne.NewSize(160, 60)) @@ -80,5 +80,9 @@ func buildQueueTile(done, total int, queueColor, textColor color.Color) fyne.Can text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} text.TextSize = 18 - return container.NewMax(rect, container.NewCenter(text)) + tile := container.NewMax(rect, container.NewCenter(text)) + + // Make it tappable + tappable := NewTappable(tile, onClick) + return tappable } diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go new file mode 100644 index 0000000..634fb8a --- /dev/null +++ b/internal/ui/queueview.go @@ -0,0 +1,206 @@ +package ui + +import ( + "fmt" + "image/color" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + "git.leaktechnologies.dev/stu/VideoTools/internal/queue" +) + +// BuildQueueView creates the queue viewer UI +func BuildQueueView( + jobs []*queue.Job, + onBack func(), + onPause func(string), + onResume func(string), + onCancel func(string), + onRemove func(string), + onClear func(), + titleColor, bgColor, textColor color.Color, +) fyne.CanvasObject { + // Header + title := canvas.NewText("JOB QUEUE", titleColor) + title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + title.TextSize = 24 + + backBtn := widget.NewButton("← Back", onBack) + clearBtn := widget.NewButton("Clear Completed", onClear) + + header := container.NewBorder( + nil, nil, + backBtn, + clearBtn, + container.NewCenter(title), + ) + + // Job list + var jobItems []fyne.CanvasObject + + if len(jobs) == 0 { + emptyMsg := widget.NewLabel("No jobs in queue") + emptyMsg.Alignment = fyne.TextAlignCenter + jobItems = append(jobItems, container.NewCenter(emptyMsg)) + } else { + for _, job := range jobs { + jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, bgColor, textColor)) + } + } + + jobList := container.NewVBox(jobItems...) + scrollable := container.NewVScroll(jobList) + + body := container.NewBorder( + header, + nil, nil, nil, + scrollable, + ) + + return container.NewPadded(body) +} + +// buildJobItem creates a single job item in the queue list +func buildJobItem( + job *queue.Job, + onPause func(string), + onResume func(string), + onCancel func(string), + onRemove func(string), + bgColor, textColor color.Color, +) fyne.CanvasObject { + // Status color + statusColor := getStatusColor(job.Status) + + // Status indicator + statusRect := canvas.NewRectangle(statusColor) + statusRect.SetMinSize(fyne.NewSize(6, 0)) + + // Title and description + titleLabel := widget.NewLabel(job.Title) + titleLabel.TextStyle = fyne.TextStyle{Bold: true} + + descLabel := widget.NewLabel(job.Description) + descLabel.TextStyle = fyne.TextStyle{Italic: true} + + // Progress bar (for running jobs) + var progressWidget fyne.CanvasObject + if job.Status == queue.JobStatusRunning { + progress := widget.NewProgressBar() + 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("") + } + + // Status text + statusText := getStatusText(job) + statusLabel := widget.NewLabel(statusText) + statusLabel.TextStyle = fyne.TextStyle{Monospace: true} + + // Control buttons + 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) }), + ) + case queue.JobStatusPaused: + buttons = append(buttons, + widget.NewButton("Resume", func() { onResume(job.ID) }), + widget.NewButton("Cancel", func() { onCancel(job.ID) }), + ) + 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) }), + ) + } + + buttonBox := container.NewHBox(buttons...) + + // Info section + infoBox := container.NewVBox( + titleLabel, + descLabel, + progressWidget, + statusLabel, + ) + + // Main content + content := container.NewBorder( + nil, nil, + statusRect, + buttonBox, + infoBox, + ) + + // Card background + card := canvas.NewRectangle(bgColor) + card.CornerRadius = 4 + + item := container.NewPadded( + container.NewMax(card, content), + ) + + return item +} + +// getStatusColor returns the color for a job status +func getStatusColor(status queue.JobStatus) color.Color { + switch status { + case queue.JobStatusPending: + return color.RGBA{R: 150, G: 150, B: 150, A: 255} // Gray + case queue.JobStatusRunning: + return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue + case queue.JobStatusPaused: + return color.RGBA{R: 255, G: 193, B: 7, A: 255} // Yellow + case queue.JobStatusCompleted: + return color.RGBA{R: 76, G: 232, B: 112, A: 255} // Green + case queue.JobStatusFailed: + return color.RGBA{R: 255, G: 68, B: 68, A: 255} // Red + case queue.JobStatusCancelled: + return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange + default: + return color.Gray{Y: 128} + } +} + +// getStatusText returns a human-readable status string +func getStatusText(job *queue.Job) string { + switch job.Status { + case queue.JobStatusPending: + return fmt.Sprintf("Status: Pending | Priority: %d", job.Priority) + case queue.JobStatusRunning: + elapsed := "" + if job.StartedAt != nil { + elapsed = fmt.Sprintf(" | Elapsed: %s", time.Since(*job.StartedAt).Round(time.Second)) + } + return fmt.Sprintf("Status: Running | Progress: %.1f%%%s", job.Progress, elapsed) + case queue.JobStatusPaused: + return "Status: Paused" + case queue.JobStatusCompleted: + duration := "" + if job.StartedAt != nil && job.CompletedAt != nil { + duration = fmt.Sprintf(" | Duration: %s", job.CompletedAt.Sub(*job.StartedAt).Round(time.Second)) + } + return fmt.Sprintf("Status: Completed%s", duration) + case queue.JobStatusFailed: + return fmt.Sprintf("Status: Failed | Error: %s", job.Error) + case queue.JobStatusCancelled: + return "Status: Cancelled" + default: + return fmt.Sprintf("Status: %s", job.Status) + } +} diff --git a/main.go b/main.go index d55b784..9e464be 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ import ( "git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/modules" "git.leaktechnologies.dev/stu/VideoTools/internal/player" + "git.leaktechnologies.dev/stu/VideoTools/internal/queue" "git.leaktechnologies.dev/stu/VideoTools/internal/ui" "git.leaktechnologies.dev/stu/VideoTools/internal/utils" "github.com/hajimehoshi/oto" @@ -170,6 +171,7 @@ type appState struct { convertBusy bool convertStatus string playSess *playSession + jobQueue *queue.Queue } func (s *appState) stopPreview() { @@ -303,10 +305,129 @@ func (s *appState) showMainMenu() { } titleColor := utils.MustHex("#4CE870") - menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, titleColor, queueColor, textColor) + + // Get queue stats + var queueCompleted, queueTotal int + if s.jobQueue != nil { + _, _, completed, _ := s.jobQueue.Stats() + queueCompleted = completed + queueTotal = len(s.jobQueue.List()) + } + + menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueCompleted, queueTotal) s.setContent(container.NewPadded(menu)) } +func (s *appState) showQueue() { + s.stopPreview() + s.stopPlayer() + s.active = "queue" + + jobs := s.jobQueue.List() + + view := ui.BuildQueueView( + jobs, + s.showMainMenu, // onBack + func(id string) { // onPause + if err := s.jobQueue.Pause(id); err != nil { + logging.Debug(logging.CatSystem, "failed to pause job: %v", err) + } + s.showQueue() // Refresh + }, + func(id string) { // onResume + if err := s.jobQueue.Resume(id); err != nil { + logging.Debug(logging.CatSystem, "failed to resume job: %v", err) + } + s.showQueue() // Refresh + }, + func(id string) { // onCancel + if err := s.jobQueue.Cancel(id); err != nil { + logging.Debug(logging.CatSystem, "failed to cancel job: %v", err) + } + s.showQueue() // Refresh + }, + func(id string) { // onRemove + if err := s.jobQueue.Remove(id); err != nil { + logging.Debug(logging.CatSystem, "failed to remove job: %v", err) + } + s.showQueue() // Refresh + }, + func() { // onClear + s.jobQueue.Clear() + s.showQueue() // Refresh + }, + utils.MustHex("#4CE870"), // titleColor + gridColor, // bgColor + textColor, // textColor + ) + + s.setContent(container.NewPadded(view)) +} + +// addConvertToQueue adds a conversion job to the queue +func (s *appState) addConvertToQueue() error { + if s.source == nil { + return fmt.Errorf("no video loaded") + } + + src := s.source + cfg := s.convert + + outDir := filepath.Dir(src.Path) + outName := cfg.OutputFile() + if outName == "" { + outName = "converted" + cfg.SelectedFormat.Ext + } + outPath := filepath.Join(outDir, outName) + if outPath == src.Path { + outPath = filepath.Join(outDir, "converted-"+outName) + } + + // Create job config map + config := map[string]interface{}{ + "inputPath": src.Path, + "outputPath": outPath, + "outputBase": cfg.OutputBase, + "selectedFormat": cfg.SelectedFormat, + "quality": cfg.Quality, + "mode": cfg.Mode, + "videoCodec": cfg.VideoCodec, + "encoderPreset": cfg.EncoderPreset, + "crf": cfg.CRF, + "bitrateMode": cfg.BitrateMode, + "videoBitrate": cfg.VideoBitrate, + "targetResolution": cfg.TargetResolution, + "frameRate": cfg.FrameRate, + "pixelFormat": cfg.PixelFormat, + "hardwareAccel": cfg.HardwareAccel, + "twoPass": cfg.TwoPass, + "audioCodec": cfg.AudioCodec, + "audioBitrate": cfg.AudioBitrate, + "audioChannels": cfg.AudioChannels, + "inverseTelecine": cfg.InverseTelecine, + "coverArtPath": cfg.CoverArtPath, + "aspectHandling": cfg.AspectHandling, + "outputAspect": cfg.OutputAspect, + "sourceWidth": src.Width, + "sourceHeight": src.Height, + } + + 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, + } + + s.jobQueue.Add(job) + logging.Debug(logging.CatSystem, "added convert job to queue: %s", job.ID) + + return nil +} + func (s *appState) showModule(id string) { switch id { case "convert": @@ -322,7 +443,9 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { logging.Debug(logging.CatModule, "handleModuleDrop: no items to process") return } - // Load the first video file + + // Collect all video files (including from folders) + var videoPaths []string for _, uri := range items { logging.Debug(logging.CatModule, "handleModuleDrop: processing uri scheme=%s path=%s", uri.Scheme(), uri.Path()) if uri.Scheme() != "file" { @@ -330,20 +453,149 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { continue } path := uri.Path() - logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path) - // Load video and switch to the module - go func() { - logging.Debug(logging.CatModule, "loading video in goroutine") - s.loadVideo(path) - // After loading, switch to the module - fyne.CurrentApp().Driver().DoFromGoroutine(func() { - logging.Debug(logging.CatModule, "showing module %s after load", moduleID) - s.showModule(moduleID) - }, false) - }() - break + // Check if it's a directory + if info, err := os.Stat(path); err == nil && info.IsDir() { + logging.Debug(logging.CatModule, "processing directory: %s", path) + videos := s.findVideoFiles(path) + videoPaths = append(videoPaths, videos...) + } else if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } } + + logging.Debug(logging.CatModule, "found %d video files to process", len(videoPaths)) + + if len(videoPaths) == 0 { + return + } + + // If convert module and multiple files, add all to queue + if moduleID == "convert" && len(videoPaths) > 1 { + go s.batchAddToQueue(videoPaths) + return + } + + // Single file or non-convert module: load first video and show module + path := videoPaths[0] + logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path) + + go func() { + logging.Debug(logging.CatModule, "loading video in goroutine") + s.loadVideo(path) + // After loading, switch to the module + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + logging.Debug(logging.CatModule, "showing module %s after load", moduleID) + s.showModule(moduleID) + }, false) + }() +} + +// isVideoFile checks if a file has a video extension +func (s *appState) isVideoFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + videoExts := []string{".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp", ".ogv"} + for _, videoExt := range videoExts { + if ext == videoExt { + return true + } + } + return false +} + +// findVideoFiles recursively finds all video files in a directory +func (s *appState) findVideoFiles(dir string) []string { + var videos []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if !info.IsDir() && s.isVideoFile(path) { + videos = append(videos, path) + } + return nil + }) + + if err != nil { + logging.Debug(logging.CatModule, "error walking directory %s: %v", dir, err) + } + + return videos +} + +// batchAddToQueue adds multiple videos to the queue +func (s *appState) batchAddToQueue(paths []string) { + logging.Debug(logging.CatModule, "batch adding %d videos to queue", len(paths)) + + addedCount := 0 + for _, path := range paths { + // Load video metadata + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatModule, "failed to parse metadata for %s: %v", path, err) + continue + } + + // Create job config + outDir := filepath.Dir(path) + baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + outName := baseName + "-converted" + s.convert.SelectedFormat.Ext + outPath := filepath.Join(outDir, outName) + + config := map[string]interface{}{ + "inputPath": path, + "outputPath": outPath, + "outputBase": baseName + "-converted", + "selectedFormat": s.convert.SelectedFormat, + "quality": s.convert.Quality, + "mode": s.convert.Mode, + "videoCodec": s.convert.VideoCodec, + "encoderPreset": s.convert.EncoderPreset, + "crf": s.convert.CRF, + "bitrateMode": s.convert.BitrateMode, + "videoBitrate": s.convert.VideoBitrate, + "targetResolution": s.convert.TargetResolution, + "frameRate": s.convert.FrameRate, + "pixelFormat": s.convert.PixelFormat, + "hardwareAccel": s.convert.HardwareAccel, + "twoPass": s.convert.TwoPass, + "audioCodec": s.convert.AudioCodec, + "audioBitrate": s.convert.AudioBitrate, + "audioChannels": s.convert.AudioChannels, + "inverseTelecine": s.convert.InverseTelecine, + "coverArtPath": "", + "aspectHandling": s.convert.AspectHandling, + "outputAspect": s.convert.OutputAspect, + "sourceWidth": src.Width, + "sourceHeight": src.Height, + } + + 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, + } + + s.jobQueue.Add(job) + addedCount++ + } + + // Show confirmation dialog + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + msg := fmt.Sprintf("Added %d video(s) to the queue!", addedCount) + dialog.ShowInformation("Batch Add", msg, s.window) + + // Load the first video so user can adjust settings if needed + if len(paths) > 0 { + s.loadVideo(paths[0]) + s.showModule("convert") + } + }, false) } func (s *appState) showConvertView(file *videoSource) { @@ -360,7 +612,258 @@ func (s *appState) showConvertView(file *videoSource) { s.setContent(buildConvertView(s, s.source)) } +// jobExecutor executes a job from the queue +func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + logging.Debug(logging.CatSystem, "executing job %s: %s", job.ID, job.Title) + + switch job.Type { + case queue.JobTypeConvert: + return s.executeConvertJob(ctx, job, progressCallback) + case queue.JobTypeMerge: + return fmt.Errorf("merge jobs not yet implemented") + case queue.JobTypeTrim: + return fmt.Errorf("trim jobs not yet implemented") + case queue.JobTypeFilter: + return fmt.Errorf("filter jobs not yet implemented") + case queue.JobTypeUpscale: + return fmt.Errorf("upscale jobs not yet implemented") + case queue.JobTypeAudio: + return fmt.Errorf("audio jobs not yet implemented") + case queue.JobTypeThumb: + return fmt.Errorf("thumb jobs not yet implemented") + default: + return fmt.Errorf("unknown job type: %s", job.Type) + } +} + +// executeConvertJob executes a conversion job from the queue +func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + inputPath := cfg["inputPath"].(string) + outputPath := cfg["outputPath"].(string) + + // Build FFmpeg arguments + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + "-i", inputPath, + } + + // Add cover art if available + coverArtPath, _ := cfg["coverArtPath"].(string) + hasCoverArt := coverArtPath != "" + if hasCoverArt { + args = append(args, "-i", coverArtPath) + } + + // Hardware acceleration + hardwareAccel, _ := cfg["hardwareAccel"].(string) + if hardwareAccel != "none" && hardwareAccel != "" { + switch hardwareAccel { + case "nvenc": + args = append(args, "-hwaccel", "cuda") + case "vaapi": + args = append(args, "-hwaccel", "vaapi") + case "qsv": + args = append(args, "-hwaccel", "qsv") + case "videotoolbox": + args = append(args, "-hwaccel", "videotoolbox") + } + } + + // Video filters + var vf []string + + // Deinterlacing + if inverseTelecine, _ := cfg["inverseTelecine"].(bool); inverseTelecine { + vf = append(vf, "yadif") + } + + // Scaling/Resolution + targetResolution, _ := cfg["targetResolution"].(string) + if targetResolution != "" && targetResolution != "Source" { + var scaleFilter string + switch targetResolution { + case "720p": + scaleFilter = "scale=-2:720" + case "1080p": + scaleFilter = "scale=-2:1080" + case "1440p": + scaleFilter = "scale=-2:1440" + case "4K": + scaleFilter = "scale=-2:2160" + } + if scaleFilter != "" { + vf = append(vf, scaleFilter) + } + } + + // Aspect ratio conversion + sourceWidth, _ := cfg["sourceWidth"].(int) + sourceHeight, _ := cfg["sourceHeight"].(int) + srcAspect := utils.AspectRatioFloat(sourceWidth, sourceHeight) + outputAspect, _ := cfg["outputAspect"].(string) + aspectHandling, _ := cfg["aspectHandling"].(string) + + // Create temp source for aspect calculation + tempSrc := &videoSource{Width: sourceWidth, Height: sourceHeight} + targetAspect := resolveTargetAspect(outputAspect, tempSrc) + if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) { + vf = append(vf, aspectFilters(targetAspect, aspectHandling)...) + } + + // Frame rate + frameRate, _ := cfg["frameRate"].(string) + if frameRate != "" && frameRate != "Source" { + vf = append(vf, "fps="+frameRate) + } + + if len(vf) > 0 { + args = append(args, "-vf", strings.Join(vf, ",")) + } + + // Video codec + videoCodec, _ := cfg["videoCodec"].(string) + if videoCodec == "Copy" { + args = append(args, "-c:v", "copy") + } else { + // Determine the actual codec to use + actualCodec := determineVideoCodec(convertConfig{ + VideoCodec: videoCodec, + HardwareAccel: hardwareAccel, + }) + args = append(args, "-c:v", actualCodec) + + // Bitrate mode and quality + bitrateMode, _ := cfg["bitrateMode"].(string) + if bitrateMode == "CRF" || bitrateMode == "" { + crfStr, _ := cfg["crf"].(string) + if crfStr == "" { + quality, _ := cfg["quality"].(string) + crfStr = crfForQuality(quality) + } + if actualCodec == "libx264" || actualCodec == "libx265" || actualCodec == "libvpx-vp9" { + args = append(args, "-crf", crfStr) + } + } else if bitrateMode == "CBR" { + if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { + args = append(args, "-b:v", videoBitrate, "-minrate", videoBitrate, "-maxrate", videoBitrate, "-bufsize", videoBitrate) + } + } else if bitrateMode == "VBR" { + if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { + args = append(args, "-b:v", videoBitrate) + } + } + + // Encoder preset + if encoderPreset, _ := cfg["encoderPreset"].(string); encoderPreset != "" && (actualCodec == "libx264" || actualCodec == "libx265") { + args = append(args, "-preset", encoderPreset) + } + + // Pixel format + if pixelFormat, _ := cfg["pixelFormat"].(string); pixelFormat != "" { + args = append(args, "-pix_fmt", pixelFormat) + } + } + + // Audio codec and settings + audioCodec, _ := cfg["audioCodec"].(string) + if audioCodec == "Copy" { + args = append(args, "-c:a", "copy") + } else { + actualAudioCodec := determineAudioCodec(convertConfig{AudioCodec: audioCodec}) + args = append(args, "-c:a", actualAudioCodec) + + if audioBitrate, _ := cfg["audioBitrate"].(string); audioBitrate != "" && actualAudioCodec != "flac" { + args = append(args, "-b:a", audioBitrate) + } + + if audioChannels, _ := cfg["audioChannels"].(string); audioChannels != "" && audioChannels != "Source" { + switch audioChannels { + case "Mono": + args = append(args, "-ac", "1") + case "Stereo": + args = append(args, "-ac", "2") + case "5.1": + args = append(args, "-ac", "6") + } + } + } + + // Map cover art + if hasCoverArt { + args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v") + args = append(args, "-c:v:1", "png") + args = append(args, "-disposition:v:1", "attached_pic") + } + + // Format-specific settings + selectedFormat := cfg["selectedFormat"].(formatOption) + if strings.EqualFold(selectedFormat.Ext, ".mp4") || strings.EqualFold(selectedFormat.Ext, ".mov") { + args = append(args, "-movflags", "+faststart") + } + + // Progress feed + args = append(args, "-progress", "pipe:1", "-nostats") + args = append(args, outputPath) + + logging.Debug(logging.CatFFMPEG, "queue convert command: ffmpeg %s", strings.Join(args, " ")) + + // Execute FFmpeg + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffmpeg: %w", err) + } + + // Parse progress + scanner := bufio.NewScanner(stdout) + var duration float64 + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "out_time_ms=") { + val := strings.TrimPrefix(line, "out_time_ms=") + if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 { + currentSec := float64(ms) / 1000000.0 + if duration > 0 { + progress := (currentSec / duration) * 100.0 + if progress > 100 { + progress = 100 + } + progressCallback(progress) + } + } + } else if strings.HasPrefix(line, "duration_ms=") { + val := strings.TrimPrefix(line, "duration_ms=") + if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 { + duration = float64(ms) / 1000000.0 + } + } + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("ffmpeg failed: %w", err) + } + + logging.Debug(logging.CatFFMPEG, "queue conversion completed: %s", outputPath) + return nil +} + func (s *appState) shutdown() { + // Save queue before shutting down + 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) + } + } + s.stopPlayer() if s.player != nil { s.player.Close() @@ -462,6 +965,25 @@ func runGUI() { playerMuted: false, playerPaused: true, } + + // Initialize job queue + state.jobQueue = queue.New(state.jobExecutor) + state.jobQueue.SetChangeCallback(func() { + // 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() w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) { state.handleDrop(pos, items) @@ -603,7 +1125,13 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { state.showMainMenu() }) back.Importance = widget.LowImportance - backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer())) + + // Queue button to view queue + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + + backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer(), queueBtn)) var updateCover func(string) var coverDisplay *widget.Label @@ -975,7 +1503,20 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { }) cancelBtn.Importance = widget.DangerImportance cancelBtn.Disable() - convertBtn = widget.NewButton("CONVERT", 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) + } + }) + if src == nil { + addQueueBtn.Disable() + } + + convertBtn = widget.NewButton("CONVERT NOW", func() { state.startConvert(statusLabel, convertBtn, cancelBtn, activity) }) convertBtn.Importance = widget.HighImportance @@ -985,9 +1526,10 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { if state.convertBusy { convertBtn.Disable() cancelBtn.Enable() + addQueueBtn.Disable() } - actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, 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