Add progress bars to In Progress tab and fix lossless quality compatibility

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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Stu Leak 2025-12-18 18:27:24 -05:00
parent 12b2b221b9
commit 86d2f2b835
3 changed files with 101 additions and 31 deletions

View File

@ -40,6 +40,7 @@ type HistoryEntry struct {
CompletedAt *time.Time
Error string
FFmpegCmd string
Progress float64 // 0.0 to 1.0 for in-progress jobs
}
// BuildMainMenu creates the main menu view with module tiles grouped by category
@ -258,6 +259,21 @@ func buildHistoryItem(
timeLabel := widget.NewLabel(timeStr)
timeLabel.TextStyle = fyne.TextStyle{Monospace: true}
// Progress bar for in-progress jobs
contentItems := []fyne.CanvasObject{
container.NewHBox(headerItems...),
titleLabel,
timeLabel,
}
if entry.Status == queue.JobStatusRunning || entry.Status == queue.JobStatusPending {
// Add progress bar for active jobs
moduleCol := ModuleColor(entry.Type)
progressBar := NewStripedProgress(moduleCol)
progressBar.SetProgress(entry.Progress)
contentItems = append(contentItems, progressBar)
}
// Status color bar
statusColor := GetStatusColor(entry.Status)
statusRect := canvas.NewRectangle(statusColor)
@ -265,11 +281,7 @@ func buildHistoryItem(
content := container.NewBorder(
nil, nil, statusRect, nil,
container.NewVBox(
container.NewHBox(headerItems...),
titleLabel,
timeLabel,
),
container.NewVBox(contentItems...),
)
card := canvas.NewRectangle(bgColor)

View File

@ -16,8 +16,8 @@ import (
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
// stripedProgress renders a progress bar with a tinted stripe pattern.
type stripedProgress struct {
// StripedProgress renders a progress bar with a tinted stripe pattern.
type StripedProgress struct {
widget.BaseWidget
progress float64
color color.Color
@ -25,8 +25,9 @@ type stripedProgress struct {
offset float64
}
func newStripedProgress(col color.Color) *stripedProgress {
sp := &stripedProgress{
// 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
@ -35,7 +36,8 @@ func newStripedProgress(col color.Color) *stripedProgress {
return sp
}
func (s *stripedProgress) SetProgress(p float64) {
// SetProgress updates the progress value (0.0 to 1.0)
func (s *StripedProgress) SetProgress(p float64) {
if p < 0 {
p = 0
}
@ -46,7 +48,7 @@ func (s *stripedProgress) SetProgress(p float64) {
s.Refresh()
}
func (s *stripedProgress) CreateRenderer() fyne.WidgetRenderer {
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 {
@ -79,7 +81,7 @@ func (s *stripedProgress) CreateRenderer() fyne.WidgetRenderer {
}
type stripedProgressRenderer struct {
bar *stripedProgress
bar *StripedProgress
bg *canvas.Rectangle
fill *canvas.Rectangle
stripes *canvas.Raster
@ -234,7 +236,7 @@ func buildJobItem(
descLabel.Wrapping = fyne.TextWrapWord
// Progress bar (for running jobs)
progress := newStripedProgress(moduleColor(job.Type))
progress := NewStripedProgress(ModuleColor(job.Type))
progress.SetProgress(job.Progress / 100.0)
if job.Status == queue.JobStatusCompleted {
progress.SetProgress(1.0)
@ -379,7 +381,8 @@ func getStatusText(job *queue.Job) string {
}
// moduleColor maps job types to distinct colors matching the main module colors
func moduleColor(t queue.JobType) color.Color {
// 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)

89
main.go
View File

@ -1390,6 +1390,7 @@ func (s *appState) showMainMenu() {
CreatedAt: job.CreatedAt,
StartedAt: job.StartedAt,
Error: job.Error,
Progress: job.Progress / 100.0, // Convert 0-100 to 0.0-1.0
}
activeJobs = append(activeJobs, entry)
}
@ -5187,16 +5188,29 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
updateEncodingControls func()
updateQualityVisibility func()
buildCommandPreview func()
updateQualityOptions func() // Update quality dropdown based on codec
)
qualityOptions := []string{
// Base quality options (without lossless)
baseQualityOptions := []string{
"Draft (CRF 28)",
"Standard (CRF 23)",
"Balanced (CRF 20)",
"High (CRF 18)",
"Near-Lossless (CRF 16)",
"Lossless",
}
// Helper function to check if codec supports lossless
codecSupportsLossless := func(codec string) bool {
return codec == "H.265" || codec == "AV1"
}
// Current quality options (dynamic based on codec)
qualityOptions := baseQualityOptions
if codecSupportsLossless(state.convert.VideoCodec) {
qualityOptions = append(qualityOptions, "Lossless")
}
var syncingQuality bool
qualitySelectSimple = widget.NewSelect(qualityOptions, func(value string) {
@ -5243,6 +5257,29 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
qualitySelectSimple.SetSelected(state.convert.Quality)
qualitySelectAdv.SetSelected(state.convert.Quality)
// Update quality options based on codec
updateQualityOptions = func() {
var newOptions []string
if codecSupportsLossless(state.convert.VideoCodec) {
// H.265 and AV1 support lossless
newOptions = append(baseQualityOptions, "Lossless")
} else {
// H.264, MPEG-2, etc. don't support lossless
newOptions = baseQualityOptions
// If currently set to Lossless, fall back to Near-Lossless
if state.convert.Quality == "Lossless" {
state.convert.Quality = "Near-Lossless (CRF 16)"
}
}
qualitySelectSimple.Options = newOptions
qualitySelectAdv.Options = newOptions
qualitySelectSimple.SetSelected(state.convert.Quality)
qualitySelectAdv.SetSelected(state.convert.Quality)
qualitySelectSimple.Refresh()
qualitySelectAdv.Refresh()
}
outputEntry := widget.NewEntry()
outputEntry.SetText(state.convert.OutputBase)
var updatingOutput bool
@ -5528,6 +5565,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
videoCodecSelect := widget.NewSelect([]string{"H.264", "H.265", "VP9", "AV1", "MPEG-2", "Copy"}, func(value string) {
state.convert.VideoCodec = value
logging.Debug(logging.CatUI, "video codec set to %s", value)
if updateQualityOptions != nil {
updateQualityOptions()
}
if updateQualityVisibility != nil {
updateQualityVisibility()
}
@ -6009,25 +6049,40 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
updateEncodingControls = func() {
mode := state.convert.BitrateMode
isLossless := state.convert.Quality == "Lossless"
supportsLossless := codecSupportsLossless(state.convert.VideoCodec)
hint := ""
if isLossless {
// Lossless forces CRF 0; hide bitrate/target size
if mode != "CRF" {
state.convert.BitrateMode = "CRF"
bitrateModeSelect.SetSelected("CRF")
mode = "CRF"
if isLossless && supportsLossless {
// Lossless with H.265/AV1: Allow all bitrate modes
// The lossless quality affects the encoding, but bitrate/target size still control output
switch mode {
case "CRF", "":
if crfEntry.Text != "0" {
crfEntry.SetText("0")
}
state.convert.CRF = "0"
crfEntry.Disable()
crfContainer.Show()
bitrateContainer.Hide()
targetSizeContainer.Hide()
hint = "Lossless mode with CRF 0. Perfect quality preservation for H.265/AV1."
case "CBR":
crfContainer.Hide()
bitrateContainer.Show()
targetSizeContainer.Hide()
hint = "Lossless quality with constant bitrate. May achieve smaller file size than pure lossless CRF."
case "VBR":
crfContainer.Hide()
bitrateContainer.Show()
targetSizeContainer.Hide()
hint = "Lossless quality with variable bitrate. Efficient file size while maintaining lossless quality."
case "Target Size":
crfContainer.Hide()
bitrateContainer.Hide()
targetSizeContainer.Show()
hint = "Lossless quality with target size. Calculates bitrate to achieve exact file size with best possible quality."
}
if crfEntry.Text != "0" {
crfEntry.SetText("0")
}
state.convert.CRF = "0"
crfEntry.Disable()
crfContainer.Show()
bitrateContainer.Hide()
targetSizeContainer.Hide()
hint = "Lossless forces CRF 0 for H.265/AV1; bitrate and target size are ignored."
} else {
crfEntry.Enable()
switch mode {