VideoTools/internal/ui/queueview.go
2026-01-03 23:49:25 -05:00

697 lines
18 KiB
Go

package ui
import (
"fmt"
"image"
"image/color"
"strings"
"sync"
"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
activity bool
animMu sync.Mutex
animStop chan struct{}
}
// NewStripedProgress creates a new striped progress bar with the given color
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
}
// SetProgress updates the progress value (0.0 to 1.0)
func (s *StripedProgress) SetProgress(p float64) {
if p < 0 {
p = 0
}
if p > 1 {
p = 1
}
s.progress = p
s.Refresh()
}
// SetActivity toggles the full-width animated background when progress is near zero.
func (s *StripedProgress) SetActivity(active bool) {
s.activity = active
s.Refresh()
}
// StartAnimation starts the stripe animation.
func (s *StripedProgress) StartAnimation() {
s.animMu.Lock()
if s.animStop != nil {
s.animMu.Unlock()
return
}
stop := make(chan struct{})
s.animStop = stop
s.animMu.Unlock()
ticker := time.NewTicker(80 * time.Millisecond)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
app := fyne.CurrentApp()
if app == nil {
continue
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.Refresh()
}, false)
case <-stop:
return
}
}
}()
}
// StopAnimation stops the stripe animation.
func (s *StripedProgress) StopAnimation() {
s.animMu.Lock()
if s.animStop == nil {
s.animMu.Unlock()
return
}
close(s.animStop)
s.animStop = nil
s.animMu.Unlock()
}
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))
lightAlpha := uint8(80)
darkAlpha := uint8(220)
if s.activity && s.progress <= 0 {
lightAlpha = 40
darkAlpha = 90
}
light := applyAlpha(s.color, lightAlpha)
dark := applyAlpha(s.color, darkAlpha)
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)
stripeWidth := fillWidth
if r.bar.activity && r.bar.progress <= 0 {
stripeWidth = size.Width
}
fillSize := fyne.NewSize(fillWidth, size.Height)
stripeSize := fyne.NewSize(stripeWidth, size.Height)
r.fill.Resize(fillSize)
r.fill.Move(fyne.NewPos(0, 0))
r.stripes.Resize(stripeSize)
r.stripes.Move(fyne.NewPos(0, 0))
}
func (r *stripedProgressRenderer) MinSize() fyne.Size {
return fyne.NewSize(120, 20)
}
func (r *stripedProgressRenderer) Refresh() {
// Only animate stripes when animation is active
r.bar.animMu.Lock()
shouldAnimate := r.bar.animStop != nil
r.bar.animMu.Unlock()
if shouldAnimate {
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() { r.bar.StopAnimation() }
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}
}
type queueCallbacks struct {
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)
}
type queueItemWidgets struct {
jobID string
status queue.JobStatus
container fyne.CanvasObject
titleLabel *widget.Label
descLabel *widget.Label
statusLabel *widget.Label
progress *StripedProgress
buttonBox *fyne.Container
}
type QueueView struct {
Root fyne.CanvasObject
Scroll *container.Scroll
jobList *fyne.Container
emptyLabel fyne.CanvasObject
items map[string]*queueItemWidgets
callbacks queueCallbacks
bgColor color.Color
textColor color.Color
}
func (v *QueueView) StopAnimations() {
for _, item := range v.items {
if item != nil && item.progress != nil {
item.progress.StopAnimation()
}
}
}
// 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,
) *QueueView {
// 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),
)
jobList := container.NewVBox()
emptyMsg := widget.NewLabel("No jobs in queue")
emptyMsg.Alignment = fyne.TextAlignCenter
emptyLabel := container.NewCenter(emptyMsg)
// 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)) // Removed for flexible sizing
scrollable.Offset = fyne.NewPos(0, 0)
body := container.NewBorder(
header,
nil, nil, nil,
scrollable,
)
view := &QueueView{
Root: container.NewPadded(body),
Scroll: scrollable,
jobList: jobList,
emptyLabel: emptyLabel,
items: make(map[string]*queueItemWidgets),
callbacks: queueCallbacks{
onBack: onBack,
onPause: onPause,
onResume: onResume,
onCancel: onCancel,
onRemove: onRemove,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
onPauseAll: onPauseAll,
onResumeAll: onResumeAll,
onStart: onStart,
onClear: onClear,
onClearAll: onClearAll,
onCopyError: onCopyError,
onViewLog: onViewLog,
onCopyCommand: onCopyCommand,
},
bgColor: bgColor,
textColor: textColor,
}
view.UpdateJobs(jobs)
return view
}
// buildJobItem creates a single job item in the queue list
func buildJobItem(
job *queue.Job,
queuePositions map[string]int,
callbacks queueCallbacks,
bgColor, textColor color.Color,
) *queueItemWidgets {
// 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.TextTruncate
// 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)
}
if job.Status == queue.JobStatusRunning {
progress.SetActivity(job.Progress <= 0.01)
progress.StartAnimation()
} else {
progress.SetActivity(false)
progress.StopAnimation()
}
progressWidget := progress
// Module badge
badge := BuildModuleBadge(job.Type)
// Status text
statusText := getStatusText(job, queuePositions)
statusLabel := widget.NewLabel(statusText)
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
statusLabel.Wrapping = fyne.TextTruncate
buttonBox := buildJobButtons(job, callbacks)
// 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
// card.SetMinSize(fyne.NewSize(0, 140)) // Removed for flexible sizing
item := container.NewPadded(
container.NewMax(card, content),
)
// Wrap with draggable to allow drag-to-reorder (up/down by drag direction)
wrapped := newDraggableJobItem(job.ID, item, func(id string, dir int) {
if dir < 0 {
callbacks.onMoveUp(id)
} else if dir > 0 {
callbacks.onMoveDown(id)
}
})
return &queueItemWidgets{
jobID: job.ID,
status: job.Status,
container: wrapped,
titleLabel: titleLabel,
descLabel: descLabel,
statusLabel: statusLabel,
progress: progress,
buttonBox: buttonBox,
}
}
func buildJobButtons(job *queue.Job, callbacks queueCallbacks) *fyne.Container {
var buttons []fyne.CanvasObject
if job.Status == queue.JobStatusPending || job.Status == queue.JobStatusPaused {
buttons = append(buttons,
widget.NewButton("↑", func() { callbacks.onMoveUp(job.ID) }),
widget.NewButton("↓", func() { callbacks.onMoveDown(job.ID) }),
)
}
switch job.Status {
case queue.JobStatusRunning:
buttons = append(buttons,
widget.NewButton("Copy Command", func() { callbacks.onCopyCommand(job.ID) }),
widget.NewButton("Pause", func() { callbacks.onPause(job.ID) }),
widget.NewButton("Cancel", func() { callbacks.onCancel(job.ID) }),
)
case queue.JobStatusPaused:
buttons = append(buttons,
widget.NewButton("Resume", func() { callbacks.onResume(job.ID) }),
widget.NewButton("Cancel", func() { callbacks.onCancel(job.ID) }),
)
case queue.JobStatusPending:
buttons = append(buttons,
widget.NewButton("Copy Command", func() { callbacks.onCopyCommand(job.ID) }),
widget.NewButton("Remove", func() { callbacks.onRemove(job.ID) }),
)
case queue.JobStatusCompleted, queue.JobStatusFailed, queue.JobStatusCancelled:
if job.Status == queue.JobStatusFailed && strings.TrimSpace(job.Error) != "" && callbacks.onCopyError != nil {
buttons = append(buttons,
widget.NewButton("Copy Error", func() { callbacks.onCopyError(job.ID) }),
)
}
if job.LogPath != "" && callbacks.onViewLog != nil {
buttons = append(buttons,
widget.NewButton("View Log", func() { callbacks.onViewLog(job.ID) }),
)
}
buttons = append(buttons,
widget.NewButton("Remove", func() { callbacks.onRemove(job.ID) }),
)
}
return container.NewHBox(buttons...)
}
func updateJobItem(item *queueItemWidgets, job *queue.Job, queuePositions map[string]int, callbacks queueCallbacks) {
item.titleLabel.SetText(utils.ShortenMiddle(job.Title, 60))
item.descLabel.SetText(utils.ShortenMiddle(job.Description, 90))
item.statusLabel.SetText(getStatusText(job, queuePositions))
if job.Status == queue.JobStatusCompleted {
item.progress.SetProgress(1.0)
} else {
item.progress.SetProgress(job.Progress / 100.0)
}
if job.Status == queue.JobStatusRunning {
item.progress.SetActivity(job.Progress <= 0.01)
item.progress.StartAnimation()
} else {
item.progress.SetActivity(false)
item.progress.StopAnimation()
}
if item.status != job.Status {
item.status = job.Status
item.buttonBox.Objects = buildJobButtons(job, callbacks).Objects
item.buttonBox.Refresh()
}
}
func (v *QueueView) UpdateJobs(jobs []*queue.Job) {
if len(jobs) == 0 {
v.jobList.Objects = []fyne.CanvasObject{v.emptyLabel}
v.jobList.Refresh()
return
}
queuePositions := make(map[string]int)
position := 1
for _, job := range jobs {
if job.Status == queue.JobStatusPending || job.Status == queue.JobStatusPaused {
queuePositions[job.ID] = position
position++
}
}
ordered := make([]fyne.CanvasObject, 0, len(jobs))
seen := make(map[string]struct{}, len(jobs))
for _, job := range jobs {
seen[job.ID] = struct{}{}
item := v.items[job.ID]
if item == nil {
item = buildJobItem(job, queuePositions, v.callbacks, v.bgColor, v.textColor)
v.items[job.ID] = item
} else {
updateJobItem(item, job, queuePositions, v.callbacks)
}
ordered = append(ordered, item.container)
}
for id := range v.items {
if _, ok := seen[id]; !ok {
delete(v.items, id)
}
}
v.jobList.Objects = ordered
v.jobList.Refresh()
}
// UpdateRunningStatus updates elapsed/progress text for running jobs without rebuilding the list.
func (v *QueueView) UpdateRunningStatus(jobs []*queue.Job) {
if len(jobs) == 0 {
return
}
queuePositions := make(map[string]int)
position := 1
for _, job := range jobs {
if job.Status == queue.JobStatusPending || job.Status == queue.JobStatusPaused {
queuePositions[job.ID] = position
position++
}
}
for _, job := range jobs {
if job.Status != queue.JobStatusRunning {
continue
}
item := v.items[job.ID]
if item == nil {
continue
}
updateJobItem(item, job, queuePositions, v.callbacks)
}
}
// getStatusText returns a human-readable status string
func getStatusText(job *queue.Job, queuePositions map[string]int) string {
switch job.Status {
case queue.JobStatusPending:
// Display position in queue (1 = first to run, 2 = second, etc.)
if pos, ok := queuePositions[job.ID]; ok {
return fmt.Sprintf("Status: Pending | Queue Position: %d", pos)
}
return "Status: Pending"
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:
// Display position in queue for paused jobs too
if pos, ok := queuePositions[job.ID]; ok {
return fmt.Sprintf("Status: Paused | Queue Position: %d", pos)
}
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)
}
}
// ModuleColor returns rainbow ROYGBIV colors matching main module palette
func ModuleColor(t queue.JobType) color.Color {
switch t {
case queue.JobTypeConvert:
return color.RGBA{R: 103, G: 58, B: 183, A: 255} // Deep Purple (#673AB7)
case queue.JobTypeMerge:
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // Green (#4CAF50)
case queue.JobTypeTrim:
return color.RGBA{R: 255, G: 235, B: 59, A: 255} // Yellow (#FFEB3B)
case queue.JobTypeFilter:
return color.RGBA{R: 0, G: 188, B: 212, A: 255} // Cyan (#00BCD4)
case queue.JobTypeUpscale:
return color.RGBA{R: 156, G: 39, B: 176, A: 255} // Purple (#9C27B0)
case queue.JobTypeAudio:
return color.RGBA{R: 255, G: 193, B: 7, A: 255} // Amber (#FFC107)
case queue.JobTypeThumb:
return color.RGBA{R: 0, G: 172, B: 193, A: 255} // Dark Cyan (#00ACC1)
case queue.JobTypeAuthor:
return color.RGBA{R: 255, G: 87, B: 34, A: 255} // Deep Orange (#FF5722)
case queue.JobTypeRip:
return color.RGBA{R: 255, G: 152, B: 0, A: 255} // Orange (#FF9800)
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
}