From 86d2f2b835baa4315e894b507f8a9e9342cd72e5 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Thu, 18 Dec 2025 18:27:24 -0500 Subject: [PATCH] Add progress bars to In Progress tab and fix lossless quality compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/ui/mainmenu.go | 22 +++++++--- internal/ui/queueview.go | 21 ++++++---- main.go | 89 ++++++++++++++++++++++++++++++++-------- 3 files changed, 101 insertions(+), 31 deletions(-) diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index 0a68a17..38be4f7 100644 --- a/internal/ui/mainmenu.go +++ b/internal/ui/mainmenu.go @@ -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) diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go index d527ec3..12d0aca 100644 --- a/internal/ui/queueview.go +++ b/internal/ui/queueview.go @@ -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) diff --git a/main.go b/main.go index a1e2d21..e8565a7 100644 --- a/main.go +++ b/main.go @@ -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 {