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, 180)) stripes := canvas.NewRaster(func(w, h int) image.Image { img := image.NewRGBA(image.Rect(0, 0, w, h)) light := applyAlpha(s.color, 90) dark := applyAlpha(s.color, 140) 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, 14) } 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 }