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:
parent
18b45db7de
commit
0bfdd3258d
|
|
@ -299,7 +299,13 @@ func (c *ffplayController) startLocked(offset float64) error {
|
||||||
env = append(env, fmt.Sprintf("SDL_VIDEO_WINDOW_POS=%s", pos))
|
env = append(env, fmt.Sprintf("SDL_VIDEO_WINDOW_POS=%s", pos))
|
||||||
}
|
}
|
||||||
if os.Getenv("SDL_VIDEODRIVER") == "" {
|
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") == "" {
|
if os.Getenv("XDG_RUNTIME_DIR") == "" {
|
||||||
run := fmt.Sprintf("/run/user/%d", os.Getuid())
|
run := fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||||
|
|
@ -330,8 +336,9 @@ func (c *ffplayController) startLocked(offset float64) error {
|
||||||
c.ctx = ctx
|
c.ctx = ctx
|
||||||
c.cancel = cancel
|
c.cancel = cancel
|
||||||
|
|
||||||
// Best-effort window placement via xdotool in case WM ignores SDL hints.
|
// Best-effort window placement via xdotool (X11 only) if available and not on Wayland.
|
||||||
if c.winW > 0 && c.winH > 0 {
|
// 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) {
|
go func(title string, x, y, w, h int) {
|
||||||
time.Sleep(120 * time.Millisecond)
|
time.Sleep(120 * time.Millisecond)
|
||||||
ffID := pickLastID(exec.Command("xdotool", "search", "--name", title))
|
ffID := pickLastID(exec.Command("xdotool", "search", "--name", title))
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ func (q *Queue) notifyChange() {
|
||||||
// Add adds a job to the queue
|
// Add adds a job to the queue
|
||||||
func (q *Queue) Add(job *Job) {
|
func (q *Queue) Add(job *Job) {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
|
||||||
if job.ID == "" {
|
if job.ID == "" {
|
||||||
job.ID = generateID()
|
job.ID = generateID()
|
||||||
|
|
@ -107,13 +106,16 @@ func (q *Queue) Add(job *Job) {
|
||||||
}
|
}
|
||||||
|
|
||||||
q.jobs = append(q.jobs, job)
|
q.jobs = append(q.jobs, job)
|
||||||
|
q.rebalancePrioritiesLocked()
|
||||||
|
q.mu.Unlock()
|
||||||
q.notifyChange()
|
q.notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove removes a job from the queue by ID
|
// Remove removes a job from the queue by ID
|
||||||
func (q *Queue) Remove(id string) error {
|
func (q *Queue) Remove(id string) error {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
var removed bool
|
||||||
|
|
||||||
for i, job := range q.jobs {
|
for i, job := range q.jobs {
|
||||||
if job.ID == id {
|
if job.ID == id {
|
||||||
|
|
@ -122,10 +124,16 @@ func (q *Queue) Remove(id string) error {
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
q.jobs = append(q.jobs[:i], q.jobs[i+1:]...)
|
q.jobs = append(q.jobs[:i], q.jobs[i+1:]...)
|
||||||
q.notifyChange()
|
q.rebalancePrioritiesLocked()
|
||||||
return nil
|
removed = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
if removed {
|
||||||
|
q.notifyChange()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("job not found: %s", id)
|
return fmt.Errorf("job not found: %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,8 +155,12 @@ func (q *Queue) List() []*Job {
|
||||||
q.mu.RLock()
|
q.mu.RLock()
|
||||||
defer q.mu.RUnlock()
|
defer q.mu.RUnlock()
|
||||||
|
|
||||||
|
// Return a copy of the jobs to avoid races on the live queue state
|
||||||
result := make([]*Job, len(q.jobs))
|
result := make([]*Job, len(q.jobs))
|
||||||
copy(result, q.jobs)
|
for i, job := range q.jobs {
|
||||||
|
clone := *job
|
||||||
|
result[i] = &clone
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,57 +187,79 @@ func (q *Queue) Stats() (pending, running, completed, failed int) {
|
||||||
// Pause pauses a running job
|
// Pause pauses a running job
|
||||||
func (q *Queue) Pause(id string) error {
|
func (q *Queue) Pause(id string) error {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
result := fmt.Errorf("job not found: %s", id)
|
||||||
|
|
||||||
for _, job := range q.jobs {
|
for _, job := range q.jobs {
|
||||||
if job.ID == id {
|
if job.ID == id {
|
||||||
if job.Status != JobStatusRunning {
|
if job.Status != JobStatusRunning {
|
||||||
return fmt.Errorf("job is not running")
|
result = fmt.Errorf("job is not running")
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if job.cancel != nil {
|
if job.cancel != nil {
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
job.Status = JobStatusPaused
|
job.Status = JobStatusPaused
|
||||||
q.notifyChange()
|
// Keep position; just stop current run
|
||||||
return nil
|
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
|
// Resume resumes a paused job
|
||||||
func (q *Queue) Resume(id string) error {
|
func (q *Queue) Resume(id string) error {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
result := fmt.Errorf("job not found: %s", id)
|
||||||
|
|
||||||
for _, job := range q.jobs {
|
for _, job := range q.jobs {
|
||||||
if job.ID == id {
|
if job.ID == id {
|
||||||
if job.Status != JobStatusPaused {
|
if job.Status != JobStatusPaused {
|
||||||
return fmt.Errorf("job is not paused")
|
result = fmt.Errorf("job is not paused")
|
||||||
|
break
|
||||||
}
|
}
|
||||||
job.Status = JobStatusPending
|
job.Status = JobStatusPending
|
||||||
q.notifyChange()
|
// Keep position; move selection via priorities
|
||||||
return nil
|
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
|
// Cancel cancels a job
|
||||||
func (q *Queue) Cancel(id string) error {
|
func (q *Queue) Cancel(id string) error {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
|
||||||
|
var cancelled bool
|
||||||
|
now := time.Now()
|
||||||
for _, job := range q.jobs {
|
for _, job := range q.jobs {
|
||||||
if job.ID == id {
|
if job.ID == id {
|
||||||
if job.Status == JobStatusRunning && job.cancel != nil {
|
if job.Status == JobStatusRunning && job.cancel != nil {
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
job.Status = JobStatusCancelled
|
job.Status = JobStatusCancelled
|
||||||
q.notifyChange()
|
job.CompletedAt = &now
|
||||||
return nil
|
q.rebalancePrioritiesLocked()
|
||||||
|
cancelled = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
if cancelled {
|
||||||
|
q.notifyChange()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("job not found: %s", id)
|
return fmt.Errorf("job not found: %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,6 +283,37 @@ func (q *Queue) Stop() {
|
||||||
q.running = false
|
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
|
// processJobs continuously processes pending jobs
|
||||||
func (q *Queue) processJobs() {
|
func (q *Queue) processJobs() {
|
||||||
for {
|
for {
|
||||||
|
|
@ -295,13 +360,28 @@ func (q *Queue) processJobs() {
|
||||||
// Update job status
|
// Update job status
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
now = time.Now()
|
now = time.Now()
|
||||||
nextJob.CompletedAt = &now
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nextJob.Status = JobStatusFailed
|
if ctx.Err() == context.Canceled {
|
||||||
nextJob.Error = err.Error()
|
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 {
|
} else {
|
||||||
nextJob.Status = JobStatusCompleted
|
nextJob.Status = JobStatusCompleted
|
||||||
nextJob.Progress = 100.0
|
nextJob.Progress = 100.0
|
||||||
|
nextJob.CompletedAt = &now
|
||||||
}
|
}
|
||||||
nextJob.cancel = nil
|
nextJob.cancel = nil
|
||||||
q.mu.Unlock()
|
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
|
// Save saves the queue to a JSON file
|
||||||
func (q *Queue) Save(path string) error {
|
func (q *Queue) Save(path string) error {
|
||||||
q.mu.RLock()
|
q.mu.RLock()
|
||||||
|
|
@ -348,7 +466,6 @@ func (q *Queue) Load(path string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
|
||||||
// Reset running jobs to pending
|
// Reset running jobs to pending
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
|
|
@ -359,6 +476,8 @@ func (q *Queue) Load(path string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
q.jobs = jobs
|
q.jobs = jobs
|
||||||
|
q.rebalancePrioritiesLocked()
|
||||||
|
q.mu.Unlock()
|
||||||
q.notifyChange()
|
q.notifyChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -366,7 +485,9 @@ func (q *Queue) Load(path string) error {
|
||||||
// Clear removes all completed, failed, and cancelled jobs
|
// Clear removes all completed, failed, and cancelled jobs
|
||||||
func (q *Queue) Clear() {
|
func (q *Queue) Clear() {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
// Cancel any running jobs before filtering
|
||||||
|
q.cancelRunningLocked()
|
||||||
|
|
||||||
filtered := make([]*Job, 0)
|
filtered := make([]*Job, 0)
|
||||||
for _, job := range q.jobs {
|
for _, job := range q.jobs {
|
||||||
|
|
@ -375,15 +496,22 @@ func (q *Queue) Clear() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
q.jobs = filtered
|
q.jobs = filtered
|
||||||
|
q.rebalancePrioritiesLocked()
|
||||||
|
q.mu.Unlock()
|
||||||
q.notifyChange()
|
q.notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAll removes all jobs from the queue
|
// ClearAll removes all jobs from the queue
|
||||||
func (q *Queue) ClearAll() {
|
func (q *Queue) ClearAll() {
|
||||||
q.mu.Lock()
|
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.jobs = make([]*Job, 0)
|
||||||
|
q.rebalancePrioritiesLocked()
|
||||||
|
q.mu.Unlock()
|
||||||
q.notifyChange()
|
q.notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,3 +519,24 @@ func (q *Queue) ClearAll() {
|
||||||
func generateID() string {
|
func generateID() string {
|
||||||
return fmt.Sprintf("job-%d", time.Now().UnixNano())
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,12 @@ type ModuleInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildMainMenu creates the main menu view with module tiles
|
// 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 := canvas.NewText("VIDEOTOOLS", titleColor)
|
||||||
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
title.TextSize = 28
|
title.TextSize = 28
|
||||||
|
|
||||||
queueTile := buildQueueTile(queueActive, queueTotal, queueColor, textColor, onQueueClick)
|
queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick)
|
||||||
|
|
||||||
header := container.New(layout.NewHBoxLayout(),
|
header := container.New(layout.NewHBoxLayout(),
|
||||||
title,
|
title,
|
||||||
|
|
@ -70,12 +70,12 @@ func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fy
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildQueueTile creates the queue status tile
|
// 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 := canvas.NewRectangle(queueColor)
|
||||||
rect.CornerRadius = 8
|
rect.CornerRadius = 8
|
||||||
rect.SetMinSize(fyne.NewSize(160, 60))
|
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.Alignment = fyne.TextAlignCenter
|
||||||
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
text.TextSize = 18
|
text.TextSize = 18
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/canvas"
|
"fyne.io/fyne/v2/canvas"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||||
)
|
)
|
||||||
|
|
@ -20,6 +21,11 @@ func BuildQueueView(
|
||||||
onResume func(string),
|
onResume func(string),
|
||||||
onCancel func(string),
|
onCancel func(string),
|
||||||
onRemove func(string),
|
onRemove func(string),
|
||||||
|
onMoveUp func(string),
|
||||||
|
onMoveDown func(string),
|
||||||
|
onPauseAll func(),
|
||||||
|
onResumeAll func(),
|
||||||
|
onStart func(),
|
||||||
onClear func(),
|
onClear func(),
|
||||||
onClearAll func(),
|
onClearAll func(),
|
||||||
titleColor, bgColor, textColor color.Color,
|
titleColor, bgColor, textColor color.Color,
|
||||||
|
|
@ -32,16 +38,27 @@ func BuildQueueView(
|
||||||
backBtn := widget.NewButton("← Back", onBack)
|
backBtn := widget.NewButton("← Back", onBack)
|
||||||
backBtn.Importance = widget.LowImportance
|
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 := widget.NewButton("Clear Completed", onClear)
|
||||||
clearBtn.Importance = widget.LowImportance
|
clearBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
clearAllBtn := widget.NewButton("Clear All", onClearAll)
|
clearAllBtn := widget.NewButton("Clear All", onClearAll)
|
||||||
clearAllBtn.Importance = widget.DangerImportance
|
clearAllBtn.Importance = widget.DangerImportance
|
||||||
|
|
||||||
|
buttonRow := container.NewHBox(startAllBtn, pauseAllBtn, resumeAllBtn, clearAllBtn, clearBtn)
|
||||||
|
|
||||||
header := container.NewBorder(
|
header := container.NewBorder(
|
||||||
nil, nil,
|
nil, nil,
|
||||||
backBtn,
|
backBtn,
|
||||||
container.NewHBox(clearBtn, clearAllBtn),
|
buttonRow,
|
||||||
container.NewCenter(title),
|
container.NewCenter(title),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -54,7 +71,7 @@ func BuildQueueView(
|
||||||
jobItems = append(jobItems, container.NewCenter(emptyMsg))
|
jobItems = append(jobItems, container.NewCenter(emptyMsg))
|
||||||
} else {
|
} else {
|
||||||
for _, job := range jobs {
|
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),
|
onResume func(string),
|
||||||
onCancel func(string),
|
onCancel func(string),
|
||||||
onRemove func(string),
|
onRemove func(string),
|
||||||
|
onMoveUp func(string),
|
||||||
|
onMoveDown func(string),
|
||||||
bgColor, textColor color.Color,
|
bgColor, textColor color.Color,
|
||||||
) fyne.CanvasObject {
|
) fyne.CanvasObject {
|
||||||
// Status color
|
// Status color
|
||||||
|
|
@ -94,18 +113,15 @@ func buildJobItem(
|
||||||
descLabel.TextStyle = fyne.TextStyle{Italic: true}
|
descLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
|
||||||
// Progress bar (for running jobs)
|
// Progress bar (for running jobs)
|
||||||
var progressWidget fyne.CanvasObject
|
progress := widget.NewProgressBar()
|
||||||
if job.Status == queue.JobStatusRunning {
|
progress.SetValue(job.Progress / 100.0)
|
||||||
progress := widget.NewProgressBar()
|
if job.Status == queue.JobStatusCompleted {
|
||||||
progress.SetValue(job.Progress / 100.0)
|
|
||||||
progressWidget = progress
|
|
||||||
} else if job.Status == queue.JobStatusCompleted {
|
|
||||||
progress := widget.NewProgressBar()
|
|
||||||
progress.SetValue(1.0)
|
progress.SetValue(1.0)
|
||||||
progressWidget = progress
|
|
||||||
} else {
|
|
||||||
progressWidget = widget.NewLabel("")
|
|
||||||
}
|
}
|
||||||
|
progressWidget := progress
|
||||||
|
|
||||||
|
// Module badge
|
||||||
|
badge := buildModuleBadge(job.Type)
|
||||||
|
|
||||||
// Status text
|
// Status text
|
||||||
statusText := getStatusText(job)
|
statusText := getStatusText(job)
|
||||||
|
|
@ -114,6 +130,14 @@ func buildJobItem(
|
||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
var buttons []fyne.CanvasObject
|
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 {
|
switch job.Status {
|
||||||
case queue.JobStatusRunning:
|
case queue.JobStatusRunning:
|
||||||
buttons = append(buttons,
|
buttons = append(buttons,
|
||||||
|
|
@ -139,7 +163,7 @@ func buildJobItem(
|
||||||
|
|
||||||
// Info section
|
// Info section
|
||||||
infoBox := container.NewVBox(
|
infoBox := container.NewVBox(
|
||||||
titleLabel,
|
container.NewHBox(titleLabel, layout.NewSpacer(), badge),
|
||||||
descLabel,
|
descLabel,
|
||||||
progressWidget,
|
progressWidget,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
|
@ -161,7 +185,14 @@ func buildJobItem(
|
||||||
container.NewMax(card, content),
|
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
|
// 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)
|
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
53
main.go
|
|
@ -1413,23 +1413,8 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
dvdAspectBox := container.NewVBox(dvdAspectLabel, dvdAspectSelect, dvdInfoLabel)
|
dvdAspectBox := container.NewVBox(dvdAspectLabel, dvdAspectSelect, dvdInfoLabel)
|
||||||
dvdAspectBox.Hide() // Hidden by default
|
dvdAspectBox.Hide() // Hidden by default
|
||||||
|
|
||||||
// Show/hide DVD options based on format selection
|
// Placeholder for updateDVDOptions - will be defined after resolution/framerate selects are created
|
||||||
updateDVDOptions := func() {
|
var 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create formatSelect with callback that updates DVD options
|
// Create formatSelect with callback that updates DVD options
|
||||||
formatSelect := widget.NewSelect(formatLabels, func(value string) {
|
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)
|
logging.Debug(logging.CatUI, "format set to %s", value)
|
||||||
state.convert.SelectedFormat = opt
|
state.convert.SelectedFormat = opt
|
||||||
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1573,7 +1560,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target Resolution
|
// 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
|
state.convert.TargetResolution = value
|
||||||
logging.Debug(logging.CatUI, "target resolution set to %s", 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)
|
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
|
// Advanced mode options - full controls with organized sections
|
||||||
advancedOptions := container.NewVBox(
|
advancedOptions := container.NewVBox(
|
||||||
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||||
|
|
|
||||||
BIN
videotools
BIN
videotools
Binary file not shown.
Loading…
Reference in New Issue
Block a user