In Progress Tab Enhancements: - Added animated striped progress bars to in-progress jobs - Exported ModuleColor function for reuse across modules - Shows real-time progress (0-100%) with module-specific colors - Progress updates automatically as jobs run - Maintains consistent visual style with queue view Lossless Quality Preset Improvements: - H.265 and AV1 now support all bitrate modes with lossless quality - Lossless with Target Size mode now works for H.265/AV1 - H.264 and MPEG-2 no longer show "Lossless" option (codec limitation) - Dynamic quality dropdown updates based on selected codec - Automatic fallback to "Near-Lossless" when switching from lossless-capable codec to non-lossless codec Quality Options Logic: - Base options: Draft, Standard, Balanced, High, Near-Lossless - "Lossless" only appears for H.265 and AV1 - codecSupportsLossless() helper function checks compatibility - updateQualityOptions() refreshes dropdown when codec changes Lossless + Bitrate Mode Combinations: - Lossless + CRF: Forces CRF 0 for perfect quality - Lossless + CBR: Constant bitrate with lossless quality - Lossless + VBR: Variable bitrate with lossless quality - Lossless + Target Size: Calculates bitrate for exact file size with best possible quality (now allowed for H.265/AV1) Technical Implementation: - Added Progress field to ui.HistoryEntry struct - Exported StripedProgress widget and ModuleColor function - updateQualityOptions() function dynamically filters quality presets - updateEncodingControls() handles lossless modes per codec - Descriptive hints explain each lossless+bitrate combination This allows professional workflows where lossless quality is desired but file size constraints still need to be met using Target Size mode.
443 lines
12 KiB
Go
443 lines
12 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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// moduleColor maps job types to distinct colors matching the main module colors
|
|
// ModuleColor returns the color for a given job type
|
|
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
|
|
}
|