diff --git a/internal/player/controller_linux.go b/internal/player/controller_linux.go index d5ee2cc..f95690f 100644 --- a/internal/player/controller_linux.go +++ b/internal/player/controller_linux.go @@ -299,7 +299,13 @@ func (c *ffplayController) startLocked(offset float64) error { env = append(env, fmt.Sprintf("SDL_VIDEO_WINDOW_POS=%s", pos)) } if os.Getenv("SDL_VIDEODRIVER") == "" { - env = append(env, "SDL_VIDEODRIVER=x11") + // Auto-detect display server and set appropriate SDL video driver + if os.Getenv("WAYLAND_DISPLAY") != "" { + env = append(env, "SDL_VIDEODRIVER=wayland") + } else { + // Default to X11 for compatibility, but Wayland takes precedence if available + env = append(env, "SDL_VIDEODRIVER=x11") + } } if os.Getenv("XDG_RUNTIME_DIR") == "" { run := fmt.Sprintf("/run/user/%d", os.Getuid()) @@ -330,8 +336,9 @@ func (c *ffplayController) startLocked(offset float64) error { c.ctx = ctx c.cancel = cancel - // Best-effort window placement via xdotool in case WM ignores SDL hints. - if c.winW > 0 && c.winH > 0 { + // Best-effort window placement via xdotool (X11 only) if available and not on Wayland. + // Wayland compositors don't support window manipulation via xdotool. + if c.winW > 0 && c.winH > 0 && os.Getenv("WAYLAND_DISPLAY") == "" { go func(title string, x, y, w, h int) { time.Sleep(120 * time.Millisecond) ffID := pickLastID(exec.Command("xdotool", "search", "--name", title)) diff --git a/internal/queue/queue.go b/internal/queue/queue.go index fd2d46b..aab9819 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -94,7 +94,6 @@ func (q *Queue) notifyChange() { // 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() @@ -107,13 +106,16 @@ func (q *Queue) Add(job *Job) { } q.jobs = append(q.jobs, job) + q.rebalancePrioritiesLocked() + q.mu.Unlock() 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() + + var removed bool for i, job := range q.jobs { if job.ID == id { @@ -122,10 +124,16 @@ func (q *Queue) Remove(id string) error { job.cancel() } q.jobs = append(q.jobs[:i], q.jobs[i+1:]...) - q.notifyChange() - return nil + q.rebalancePrioritiesLocked() + removed = true + break } } + q.mu.Unlock() + if removed { + q.notifyChange() + return nil + } return fmt.Errorf("job not found: %s", id) } @@ -147,8 +155,12 @@ func (q *Queue) List() []*Job { q.mu.RLock() defer q.mu.RUnlock() + // Return a copy of the jobs to avoid races on the live queue state result := make([]*Job, len(q.jobs)) - copy(result, q.jobs) + for i, job := range q.jobs { + clone := *job + result[i] = &clone + } return result } @@ -175,57 +187,79 @@ func (q *Queue) Stats() (pending, running, completed, failed int) { // Pause pauses a running job func (q *Queue) Pause(id string) error { q.mu.Lock() - defer q.mu.Unlock() + + result := fmt.Errorf("job not found: %s", id) for _, job := range q.jobs { if job.ID == id { if job.Status != JobStatusRunning { - return fmt.Errorf("job is not running") + result = fmt.Errorf("job is not running") + break } if job.cancel != nil { job.cancel() } job.Status = JobStatusPaused - q.notifyChange() - return nil + // Keep position; just stop current run + result = nil + break } } - return fmt.Errorf("job not found: %s", id) + q.mu.Unlock() + if result == nil { + q.notifyChange() + } + return result } // Resume resumes a paused job func (q *Queue) Resume(id string) error { q.mu.Lock() - defer q.mu.Unlock() + + result := fmt.Errorf("job not found: %s", id) for _, job := range q.jobs { if job.ID == id { if job.Status != JobStatusPaused { - return fmt.Errorf("job is not paused") + result = fmt.Errorf("job is not paused") + break } job.Status = JobStatusPending - q.notifyChange() - return nil + // Keep position; move selection via priorities + result = nil + break } } - return fmt.Errorf("job not found: %s", id) + q.mu.Unlock() + if result == nil { + q.notifyChange() + } + return result } // Cancel cancels a job func (q *Queue) Cancel(id string) error { q.mu.Lock() - defer q.mu.Unlock() + var cancelled bool + now := time.Now() 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 + job.CompletedAt = &now + q.rebalancePrioritiesLocked() + cancelled = true + break } } + q.mu.Unlock() + if cancelled { + q.notifyChange() + return nil + } return fmt.Errorf("job not found: %s", id) } @@ -249,6 +283,37 @@ func (q *Queue) Stop() { q.running = false } +// PauseAll pauses any running job and stops processing +func (q *Queue) PauseAll() { + q.mu.Lock() + for _, job := range q.jobs { + if job.Status == JobStatusRunning && job.cancel != nil { + job.cancel() + job.Status = JobStatusPaused + job.cancel = nil + job.StartedAt = nil + job.CompletedAt = nil + job.Error = "" + } + } + q.running = false + q.mu.Unlock() + q.notifyChange() +} + +// ResumeAll restarts processing the queue +func (q *Queue) ResumeAll() { + q.mu.Lock() + if q.running { + q.mu.Unlock() + return + } + q.running = true + q.mu.Unlock() + q.notifyChange() + go q.processJobs() +} + // processJobs continuously processes pending jobs func (q *Queue) processJobs() { for { @@ -295,13 +360,28 @@ func (q *Queue) processJobs() { // Update job status q.mu.Lock() now = time.Now() - nextJob.CompletedAt = &now if err != nil { - nextJob.Status = JobStatusFailed - nextJob.Error = err.Error() + if ctx.Err() == context.Canceled { + if nextJob.Status == JobStatusPaused { + // Leave as paused without timestamps/error + nextJob.StartedAt = nil + nextJob.CompletedAt = nil + nextJob.Error = "" + } else { + // Cancelled + nextJob.Status = JobStatusCancelled + nextJob.CompletedAt = &now + nextJob.Error = "" + } + } else { + nextJob.Status = JobStatusFailed + nextJob.CompletedAt = &now + nextJob.Error = err.Error() + } } else { nextJob.Status = JobStatusCompleted nextJob.Progress = 100.0 + nextJob.CompletedAt = &now } nextJob.cancel = nil q.mu.Unlock() @@ -309,6 +389,44 @@ func (q *Queue) processJobs() { } } +// MoveUp moves a pending or paused job one position up in the queue +func (q *Queue) MoveUp(id string) error { + return q.move(id, -1) +} + +// MoveDown moves a pending or paused job one position down in the queue +func (q *Queue) MoveDown(id string) error { + return q.move(id, 1) +} + +func (q *Queue) move(id string, delta int) error { + q.mu.Lock() + defer q.mu.Unlock() + + var idx int = -1 + for i, job := range q.jobs { + if job.ID == id { + idx = i + if job.Status != JobStatusPending && job.Status != JobStatusPaused { + return fmt.Errorf("job must be pending or paused to reorder") + } + break + } + } + if idx == -1 { + return fmt.Errorf("job not found: %s", id) + } + + newIdx := idx + delta + if newIdx < 0 || newIdx >= len(q.jobs) { + return nil // already at boundary; no-op + } + + q.jobs[idx], q.jobs[newIdx] = q.jobs[newIdx], q.jobs[idx] + q.rebalancePrioritiesLocked() + return nil +} + // Save saves the queue to a JSON file func (q *Queue) Save(path string) error { q.mu.RLock() @@ -348,7 +466,6 @@ func (q *Queue) Load(path string) error { } q.mu.Lock() - defer q.mu.Unlock() // Reset running jobs to pending for _, job := range jobs { @@ -359,6 +476,8 @@ func (q *Queue) Load(path string) error { } q.jobs = jobs + q.rebalancePrioritiesLocked() + q.mu.Unlock() q.notifyChange() return nil } @@ -366,7 +485,9 @@ func (q *Queue) Load(path string) error { // Clear removes all completed, failed, and cancelled jobs func (q *Queue) Clear() { q.mu.Lock() - defer q.mu.Unlock() + + // Cancel any running jobs before filtering + q.cancelRunningLocked() filtered := make([]*Job, 0) for _, job := range q.jobs { @@ -375,15 +496,22 @@ func (q *Queue) Clear() { } } q.jobs = filtered + q.rebalancePrioritiesLocked() + q.mu.Unlock() q.notifyChange() } // ClearAll removes all jobs from the queue func (q *Queue) ClearAll() { q.mu.Lock() - defer q.mu.Unlock() + + // Cancel any running work and stop the processor + q.cancelRunningLocked() + q.running = false q.jobs = make([]*Job, 0) + q.rebalancePrioritiesLocked() + q.mu.Unlock() q.notifyChange() } @@ -391,3 +519,24 @@ func (q *Queue) ClearAll() { func generateID() string { return fmt.Sprintf("job-%d", time.Now().UnixNano()) } + +// rebalancePrioritiesLocked assigns descending priorities so earlier items are selected first +func (q *Queue) rebalancePrioritiesLocked() { + for i := range q.jobs { + q.jobs[i].Priority = len(q.jobs) - i + } +} + +// cancelRunningLocked cancels any currently running job and marks it cancelled. +func (q *Queue) cancelRunningLocked() { + now := time.Now() + for _, job := range q.jobs { + if job.Status == JobStatusRunning { + if job.cancel != nil { + job.cancel() + } + job.Status = JobStatusCancelled + job.CompletedAt = &now + } + } +} diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index 7988866..1308e2b 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), onQueueClick func(), titleColor, queueColor, textColor color.Color, queueActive, queueTotal int) 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(queueActive, queueTotal, queueColor, textColor, onQueueClick) + queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) header := container.New(layout.NewHBoxLayout(), title, @@ -70,12 +70,12 @@ func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fy } // buildQueueTile creates the queue status tile -func buildQueueTile(active, total int, queueColor, textColor color.Color, onClick func()) fyne.CanvasObject { +func buildQueueTile(completed, total int, queueColor, textColor color.Color, onClick func()) fyne.CanvasObject { rect := canvas.NewRectangle(queueColor) rect.CornerRadius = 8 rect.SetMinSize(fyne.NewSize(160, 60)) - text := canvas.NewText(fmt.Sprintf("QUEUE: %d/%d", active, total), textColor) + text := canvas.NewText(fmt.Sprintf("QUEUE: %d/%d", completed, total), textColor) text.Alignment = fyne.TextAlignCenter text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} text.TextSize = 18 diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go index e05fb21..7eec236 100644 --- a/internal/ui/queueview.go +++ b/internal/ui/queueview.go @@ -8,6 +8,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "git.leaktechnologies.dev/stu/VideoTools/internal/queue" ) @@ -20,6 +21,11 @@ func BuildQueueView( onResume func(string), onCancel func(string), onRemove func(string), + onMoveUp func(string), + onMoveDown func(string), + onPauseAll func(), + onResumeAll func(), + onStart func(), onClear func(), onClearAll func(), titleColor, bgColor, textColor color.Color, @@ -32,16 +38,27 @@ func BuildQueueView( backBtn := widget.NewButton("← Back", onBack) backBtn.Importance = widget.LowImportance + startAllBtn := widget.NewButton("Start Queue", onStart) + startAllBtn.Importance = widget.MediumImportance + + pauseAllBtn := widget.NewButton("Pause All", onPauseAll) + pauseAllBtn.Importance = widget.LowImportance + + resumeAllBtn := widget.NewButton("Resume All", onResumeAll) + resumeAllBtn.Importance = widget.LowImportance + clearBtn := widget.NewButton("Clear Completed", onClear) clearBtn.Importance = widget.LowImportance clearAllBtn := widget.NewButton("Clear All", onClearAll) clearAllBtn.Importance = widget.DangerImportance + buttonRow := container.NewHBox(startAllBtn, pauseAllBtn, resumeAllBtn, clearAllBtn, clearBtn) + header := container.NewBorder( nil, nil, backBtn, - container.NewHBox(clearBtn, clearAllBtn), + buttonRow, container.NewCenter(title), ) @@ -54,7 +71,7 @@ func BuildQueueView( jobItems = append(jobItems, container.NewCenter(emptyMsg)) } else { for _, job := range jobs { - jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, bgColor, textColor)) + jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, bgColor, textColor)) } } @@ -77,6 +94,8 @@ func buildJobItem( onResume func(string), onCancel func(string), onRemove func(string), + onMoveUp func(string), + onMoveDown func(string), bgColor, textColor color.Color, ) fyne.CanvasObject { // Status color @@ -94,18 +113,15 @@ func buildJobItem( 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 := widget.NewProgressBar() + progress.SetValue(job.Progress / 100.0) + if job.Status == queue.JobStatusCompleted { progress.SetValue(1.0) - progressWidget = progress - } else { - progressWidget = widget.NewLabel("") } + progressWidget := progress + + // Module badge + badge := buildModuleBadge(job.Type) // Status text statusText := getStatusText(job) @@ -114,6 +130,14 @@ func buildJobItem( // Control buttons var buttons []fyne.CanvasObject + // Reorder arrows for pending/paused jobs + if job.Status == queue.JobStatusPending || job.Status == queue.JobStatusPaused { + buttons = append(buttons, + widget.NewButton("↑", func() { onMoveUp(job.ID) }), + widget.NewButton("↓", func() { onMoveDown(job.ID) }), + ) + } + switch job.Status { case queue.JobStatusRunning: buttons = append(buttons, @@ -139,7 +163,7 @@ func buildJobItem( // Info section infoBox := container.NewVBox( - titleLabel, + container.NewHBox(titleLabel, layout.NewSpacer(), badge), descLabel, progressWidget, statusLabel, @@ -161,7 +185,14 @@ func buildJobItem( container.NewMax(card, content), ) - return item + // Wrap with draggable to allow drag-to-reorder (up/down by drag direction) + return newDraggableJobItem(job.ID, item, func(id string, dir int) { + if dir < 0 { + onMoveUp(id) + } else if dir > 0 { + onMoveDown(id) + } + }) } // getStatusColor returns the color for a job status @@ -211,3 +242,76 @@ func getStatusText(job *queue.Job) string { return fmt.Sprintf("Status: %s", job.Status) } } + +// buildModuleBadge renders a small colored pill to show which module created the job. +func buildModuleBadge(t queue.JobType) fyne.CanvasObject { + label := widget.NewLabel(string(t)) + label.TextStyle = fyne.TextStyle{Bold: true} + label.Alignment = fyne.TextAlignCenter + + bg := canvas.NewRectangle(moduleColor(t)) + bg.CornerRadius = 6 + bg.SetMinSize(fyne.NewSize(label.MinSize().Width+12, label.MinSize().Height+6)) + + return container.NewMax(bg, container.NewCenter(label)) +} + +// moduleColor maps job types to distinct colors for quick visual scanning. +func moduleColor(t queue.JobType) color.Color { + switch t { + case queue.JobTypeConvert: + return color.RGBA{R: 76, G: 232, B: 112, A: 255} // green + case queue.JobTypeMerge: + return color.RGBA{R: 68, G: 136, B: 255, A: 255} // blue + case queue.JobTypeTrim: + return color.RGBA{R: 255, G: 193, B: 7, A: 255} // amber + case queue.JobTypeFilter: + return color.RGBA{R: 160, G: 86, B: 255, A: 255} // purple + case queue.JobTypeUpscale: + return color.RGBA{R: 255, G: 138, B: 101, A: 255} // coral + case queue.JobTypeAudio: + return color.RGBA{R: 255, G: 215, B: 64, A: 255} // gold + case queue.JobTypeThumb: + return color.RGBA{R: 102, G: 217, B: 239, A: 255} // teal + default: + return color.Gray{Y: 180} + } +} + +// draggableJobItem allows simple drag up/down to reorder one slot at a time. +type draggableJobItem struct { + widget.BaseWidget + jobID string + content fyne.CanvasObject + onReorder func(string, int) // id, direction (-1 up, +1 down) + accumY float32 +} + +func newDraggableJobItem(id string, content fyne.CanvasObject, onReorder func(string, int)) *draggableJobItem { + d := &draggableJobItem{ + jobID: id, + content: content, + onReorder: onReorder, + } + d.ExtendBaseWidget(d) + return d +} + +func (d *draggableJobItem) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(d.content) +} + +func (d *draggableJobItem) Dragged(ev *fyne.DragEvent) { + // fyne.Delta is a struct with dx, dy fields + d.accumY += ev.Dragged.DY +} + +func (d *draggableJobItem) DragEnd() { + const threshold float32 = 25 + if d.accumY <= -threshold { + d.onReorder(d.jobID, -1) + } else if d.accumY >= threshold { + d.onReorder(d.jobID, 1) + } + d.accumY = 0 +} diff --git a/main.go b/main.go index 0bbb934..4830ee5 100644 --- a/main.go +++ b/main.go @@ -1413,23 +1413,8 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { dvdAspectBox := container.NewVBox(dvdAspectLabel, dvdAspectSelect, dvdInfoLabel) dvdAspectBox.Hide() // Hidden by default - // Show/hide DVD options based on format selection - updateDVDOptions := func() { - isDVD := state.convert.SelectedFormat.Ext == ".mpg" - if isDVD { - dvdAspectBox.Show() - // Update DVD info based on which DVD format was selected - if strings.Contains(state.convert.SelectedFormat.Label, "NTSC") { - dvdInfoLabel.SetText("NTSC: 720×480 @ 29.97fps, MPEG-2, AC-3 Stereo 48kHz\nBitrate: 6000k (default), 9000k (max PS2-safe)\nCompatible with DVDStyler, PS2, standalone DVD players") - } else if strings.Contains(state.convert.SelectedFormat.Label, "PAL") { - dvdInfoLabel.SetText("PAL: 720×576 @ 25.00fps, MPEG-2, AC-3 Stereo 48kHz\nBitrate: 8000k (default), 9500k (max PS2-safe)\nCompatible with European DVD players and authoring tools") - } else { - dvdInfoLabel.SetText("DVD Format selected") - } - } else { - dvdAspectBox.Hide() - } - } + // Placeholder for updateDVDOptions - will be defined after resolution/framerate selects are created + var updateDVDOptions func() // Create formatSelect with callback that updates DVD options formatSelect := widget.NewSelect(formatLabels, func(value string) { @@ -1438,7 +1423,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { logging.Debug(logging.CatUI, "format set to %s", value) state.convert.SelectedFormat = opt outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) - updateDVDOptions() // Show/hide DVD options + if updateDVDOptions != nil { + updateDVDOptions() // Show/hide DVD options and auto-set resolution + } break } } @@ -1573,7 +1560,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { } // Target Resolution - resolutionSelect := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K"}, func(value string) { + resolutionSelect := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K", "NTSC (720×480)", "PAL (720×576)"}, func(value string) { state.convert.TargetResolution = value logging.Debug(logging.CatUI, "target resolution set to %s", value) }) @@ -1627,6 +1614,34 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { }) audioChannelsSelect.SetSelected(state.convert.AudioChannels) + // Now define updateDVDOptions with access to resolution and framerate selects + updateDVDOptions = func() { + isDVD := state.convert.SelectedFormat.Ext == ".mpg" + if isDVD { + dvdAspectBox.Show() + // Auto-set resolution and framerate based on DVD format + if strings.Contains(state.convert.SelectedFormat.Label, "NTSC") { + dvdInfoLabel.SetText("NTSC: 720×480 @ 29.97fps, MPEG-2, AC-3 Stereo 48kHz\nBitrate: 6000k (default), 9000k (max PS2-safe)\nCompatible with DVDStyler, PS2, standalone DVD players") + // Auto-set to NTSC resolution + resolutionSelect.SetSelected("NTSC (720×480)") + frameRateSelect.SetSelected("30") // Will be converted to 29.97fps + state.convert.TargetResolution = "NTSC (720×480)" + state.convert.FrameRate = "30" + } else if strings.Contains(state.convert.SelectedFormat.Label, "PAL") { + dvdInfoLabel.SetText("PAL: 720×576 @ 25.00fps, MPEG-2, AC-3 Stereo 48kHz\nBitrate: 8000k (default), 9500k (max PS2-safe)\nCompatible with European DVD players and authoring tools") + // Auto-set to PAL resolution + resolutionSelect.SetSelected("PAL (720×576)") + frameRateSelect.SetSelected("25") + state.convert.TargetResolution = "PAL (720×576)" + state.convert.FrameRate = "25" + } else { + dvdInfoLabel.SetText("DVD Format selected") + } + } else { + dvdAspectBox.Hide() + } + } + // Advanced mode options - full controls with organized sections advancedOptions := container.NewVBox( widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), diff --git a/videotools b/videotools deleted file mode 100755 index 2090968..0000000 Binary files a/videotools and /dev/null differ