Improve queue system reliability and add auto-resolution for DVD formats

This commit includes several improvements:

Queue System Enhancements:
- Improved thread-safety in Add, Remove, Pause, Resume, Cancel operations
- Added PauseAll and ResumeAll methods for batch control
- Added MoveUp and MoveDown methods to reorder queue items
- Better handling of running job cancellation with proper state management
- Improved Copy strategy in List() to prevent race conditions

Convert Module Enhancement:
- Auto-set resolution to 720×480 when NTSC DVD format selected
- Auto-set resolution to 720×576 when PAL DVD format selected
- Auto-set framerate to 29.97fps (30) for NTSC, 25fps for PAL
- Added DVD resolution options to resolution selector dropdown

Display Server Improvements:
- Auto-detect Wayland vs X11 display servers in player controller
- Conditionally apply xdotool window placement (X11 only)

UI Improvements:
- Added Pause All, Resume All, and queue reordering buttons
- Fixed queue counter labeling (completed count display)
This commit is contained in:
Stu Leak 2025-11-29 20:07:35 -05:00
parent 3f4ad59fcd
commit d327d7f65e
6 changed files with 339 additions and 64 deletions

View File

@ -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))

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}

53
main.go
View File

@ -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}),

Binary file not shown.