Added "Copy Command" button to queue view for running and pending jobs,
allowing users to copy the FFmpeg command to clipboard for manual execution.
Changes:
- internal/ui/queueview.go: Add onCopyCommand parameter and buttons
- main.go: Implement onCopyCommand handler in showQueue()
The handler retrieves the job, generates the FFmpeg command with
INPUT/OUTPUT placeholders using buildFFmpegCommandFromJob(), and copies
it to the clipboard with a confirmation dialog.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
473 lines
13 KiB
Go
473 lines
13 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
|
)
|
|
|
|
// stripedProgress renders a progress bar with a tinted stripe pattern.
|
|
type stripedProgress struct {
|
|
widget.BaseWidget
|
|
progress float64
|
|
color color.Color
|
|
bg color.Color
|
|
offset float64
|
|
}
|
|
|
|
func newStripedProgress(col color.Color) *stripedProgress {
|
|
sp := &stripedProgress{
|
|
progress: 0,
|
|
color: col,
|
|
bg: color.RGBA{R: 34, G: 38, B: 48, A: 255}, // dark neutral
|
|
}
|
|
sp.ExtendBaseWidget(sp)
|
|
return sp
|
|
}
|
|
|
|
func (s *stripedProgress) SetProgress(p float64) {
|
|
if p < 0 {
|
|
p = 0
|
|
}
|
|
if p > 1 {
|
|
p = 1
|
|
}
|
|
s.progress = p
|
|
s.Refresh()
|
|
}
|
|
|
|
func (s *stripedProgress) CreateRenderer() fyne.WidgetRenderer {
|
|
bgRect := canvas.NewRectangle(s.bg)
|
|
fillRect := canvas.NewRectangle(applyAlpha(s.color, 200))
|
|
stripes := canvas.NewRaster(func(w, h int) image.Image {
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
light := applyAlpha(s.color, 80)
|
|
dark := applyAlpha(s.color, 220)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
// animate diagonal stripes using offset
|
|
if (((x + y) + int(s.offset)) / 4 % 2) == 0 {
|
|
img.Set(x, y, light)
|
|
} else {
|
|
img.Set(x, y, dark)
|
|
}
|
|
}
|
|
}
|
|
return img
|
|
})
|
|
|
|
objects := []fyne.CanvasObject{bgRect, fillRect, stripes}
|
|
|
|
r := &stripedProgressRenderer{
|
|
bar: s,
|
|
bg: bgRect,
|
|
fill: fillRect,
|
|
stripes: stripes,
|
|
objects: objects,
|
|
}
|
|
return r
|
|
}
|
|
|
|
type stripedProgressRenderer struct {
|
|
bar *stripedProgress
|
|
bg *canvas.Rectangle
|
|
fill *canvas.Rectangle
|
|
stripes *canvas.Raster
|
|
objects []fyne.CanvasObject
|
|
}
|
|
|
|
func (r *stripedProgressRenderer) Layout(size fyne.Size) {
|
|
r.bg.Resize(size)
|
|
r.bg.Move(fyne.NewPos(0, 0))
|
|
|
|
fillWidth := size.Width * float32(r.bar.progress)
|
|
fillSize := fyne.NewSize(fillWidth, size.Height)
|
|
|
|
r.fill.Resize(fillSize)
|
|
r.fill.Move(fyne.NewPos(0, 0))
|
|
|
|
r.stripes.Resize(fillSize)
|
|
r.stripes.Move(fyne.NewPos(0, 0))
|
|
}
|
|
|
|
func (r *stripedProgressRenderer) MinSize() fyne.Size {
|
|
return fyne.NewSize(120, 20)
|
|
}
|
|
|
|
func (r *stripedProgressRenderer) Refresh() {
|
|
// small drift to animate stripes
|
|
r.bar.offset += 2
|
|
r.Layout(r.bg.Size())
|
|
canvas.Refresh(r.bg)
|
|
canvas.Refresh(r.stripes)
|
|
}
|
|
|
|
func (r *stripedProgressRenderer) BackgroundColor() color.Color { return color.Transparent }
|
|
func (r *stripedProgressRenderer) Objects() []fyne.CanvasObject { return r.objects }
|
|
func (r *stripedProgressRenderer) Destroy() {}
|
|
|
|
func applyAlpha(c color.Color, alpha uint8) color.Color {
|
|
r, g, b, _ := c.RGBA()
|
|
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: alpha}
|
|
}
|
|
|
|
// 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),
|
|
onMoveUp func(string),
|
|
onMoveDown func(string),
|
|
onPauseAll func(),
|
|
onResumeAll func(),
|
|
onStart func(),
|
|
onClear func(),
|
|
onClearAll func(),
|
|
onCopyError func(string),
|
|
onViewLog func(string),
|
|
onCopyCommand func(string),
|
|
titleColor, bgColor, textColor color.Color,
|
|
) (fyne.CanvasObject, *container.Scroll) {
|
|
// Header
|
|
title := canvas.NewText("JOB QUEUE", titleColor)
|
|
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
|
title.TextSize = 24
|
|
|
|
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,
|
|
buttonRow,
|
|
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, onMoveUp, onMoveDown, onCopyError, onViewLog, onCopyCommand, bgColor, textColor))
|
|
}
|
|
}
|
|
|
|
jobList := container.NewVBox(jobItems...)
|
|
// Use a scroll container anchored to the top to avoid jumpy scroll-to-content behavior.
|
|
scrollable := container.NewScroll(jobList)
|
|
scrollable.SetMinSize(fyne.NewSize(0, 0))
|
|
scrollable.Offset = fyne.NewPos(0, 0)
|
|
|
|
body := container.NewBorder(
|
|
header,
|
|
nil, nil, nil,
|
|
scrollable,
|
|
)
|
|
|
|
return container.NewPadded(body), scrollable
|
|
}
|
|
|
|
// 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),
|
|
onMoveUp func(string),
|
|
onMoveDown func(string),
|
|
onCopyError func(string),
|
|
onViewLog func(string),
|
|
onCopyCommand 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
|
|
titleText := utils.ShortenMiddle(job.Title, 60)
|
|
descText := utils.ShortenMiddle(job.Description, 90)
|
|
|
|
titleLabel := widget.NewLabel(titleText)
|
|
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
|
|
|
|
descLabel := widget.NewLabel(descText)
|
|
descLabel.TextStyle = fyne.TextStyle{Italic: true}
|
|
descLabel.Wrapping = fyne.TextWrapWord
|
|
|
|
// Progress bar (for running jobs)
|
|
progress := newStripedProgress(moduleColor(job.Type))
|
|
progress.SetProgress(job.Progress / 100.0)
|
|
if job.Status == queue.JobStatusCompleted {
|
|
progress.SetProgress(1.0)
|
|
}
|
|
progressWidget := progress
|
|
|
|
// Module badge
|
|
badge := buildModuleBadge(job.Type)
|
|
|
|
// Status text
|
|
statusText := getStatusText(job)
|
|
statusLabel := widget.NewLabel(statusText)
|
|
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
|
statusLabel.Wrapping = fyne.TextWrapWord
|
|
|
|
// 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,
|
|
widget.NewButton("Copy Command", func() { onCopyCommand(job.ID) }),
|
|
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("Copy Command", func() { onCopyCommand(job.ID) }),
|
|
widget.NewButton("Cancel", func() { onCancel(job.ID) }),
|
|
)
|
|
case queue.JobStatusCompleted, queue.JobStatusFailed, queue.JobStatusCancelled:
|
|
if job.Status == queue.JobStatusFailed && strings.TrimSpace(job.Error) != "" && onCopyError != nil {
|
|
buttons = append(buttons,
|
|
widget.NewButton("Copy Error", func() { onCopyError(job.ID) }),
|
|
)
|
|
}
|
|
if job.LogPath != "" && onViewLog != nil {
|
|
buttons = append(buttons,
|
|
widget.NewButton("View Log", func() { onViewLog(job.ID) }),
|
|
)
|
|
}
|
|
buttons = append(buttons,
|
|
widget.NewButton("Remove", func() { onRemove(job.ID) }),
|
|
)
|
|
}
|
|
|
|
buttonBox := container.NewHBox(buttons...)
|
|
|
|
// Info section
|
|
infoBox := container.NewVBox(
|
|
container.NewHBox(titleLabel, layout.NewSpacer(), badge),
|
|
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),
|
|
)
|
|
|
|
// 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
|
|
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))
|
|
}
|
|
|
|
// Add FPS and speed info if available in Config
|
|
var extras string
|
|
if job.Config != nil {
|
|
if fps, ok := job.Config["fps"].(float64); ok && fps > 0 {
|
|
extras += fmt.Sprintf(" | %.0f fps", fps)
|
|
}
|
|
if speed, ok := job.Config["speed"].(float64); ok && speed > 0 {
|
|
extras += fmt.Sprintf(" | %.2fx", speed)
|
|
}
|
|
if etaDuration, ok := job.Config["eta"].(time.Duration); ok && etaDuration > 0 {
|
|
extras += fmt.Sprintf(" | ETA %s", etaDuration.Round(time.Second))
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("Status: Running | Progress: %.1f%%%s%s", job.Progress, elapsed, extras)
|
|
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:
|
|
// Truncate error to prevent UI overflow
|
|
errMsg := job.Error
|
|
maxLen := 150
|
|
if len(errMsg) > maxLen {
|
|
errMsg = errMsg[:maxLen] + "… (see Copy Error button for full message)"
|
|
}
|
|
return fmt.Sprintf("Status: Failed | Error: %s", errMsg)
|
|
case queue.JobStatusCancelled:
|
|
return "Status: Cancelled"
|
|
default:
|
|
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 matching the main module colors
|
|
func moduleColor(t queue.JobType) color.Color {
|
|
switch t {
|
|
case queue.JobTypeConvert:
|
|
return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF)
|
|
case queue.JobTypeMerge:
|
|
return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue (#4488FF)
|
|
case queue.JobTypeTrim:
|
|
return color.RGBA{R: 68, G: 221, B: 255, A: 255} // Cyan (#44DDFF)
|
|
case queue.JobTypeFilter:
|
|
return color.RGBA{R: 68, G: 255, B: 136, A: 255} // Green (#44FF88)
|
|
case queue.JobTypeUpscale:
|
|
return color.RGBA{R: 170, G: 255, B: 68, A: 255} // Yellow-Green (#AAFF44)
|
|
case queue.JobTypeAudio:
|
|
return color.RGBA{R: 255, G: 215, B: 68, A: 255} // Yellow (#FFD744)
|
|
case queue.JobTypeThumb:
|
|
return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange (#FF8844)
|
|
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
|
|
}
|