- Increase striped progress bar contrast (light: 90→60, dark: 140→200) - Increase fill opacity (180→200) for better visibility - Increase progress bar height from 14px to 20px across both striped and standard bars - Makes progress bars more visible and easier to read at a glance The striped gradient now has much clearer distinction between light and dark stripes, and the increased thickness makes progress easier to track visually.
468 lines
13 KiB
Go
468 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, 60)
|
|
dark := applyAlpha(s.color, 200)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
// animate diagonal stripes using offset
|
|
if (((x + y) + int(s.offset)) / 6 % 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 += 1
|
|
r.Layout(r.bg.Size())
|
|
canvas.Refresh(r.bg)
|
|
}
|
|
|
|
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),
|
|
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, 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),
|
|
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("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:
|
|
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
|
|
}
|