Compare commits

..

No commits in common. "e923715b957172c2e3e9d00e22d077d0e8ba292a" and "1051329763bd591a4aecf9eea6825cdb54badd61" have entirely different histories.

3 changed files with 67 additions and 271 deletions

39
DONE.md
View File

@ -2,44 +2,7 @@
This file tracks completed features, fixes, and milestones.
## Version 0.1.0-dev20+ (2025-12-28) - Queue UI Performance & Workflow Improvements
### Workflow Enhancements
- ✅ **Benchmark Result Caching**
- Benchmark results now persist across app restarts
- Opening Benchmark module shows cached results instead of auto-running
- Clear timestamp display (e.g., "Showing cached results from December 28, 2025 at 2:45 PM")
- "Run New Benchmark" button available when viewing cached results
- Auto-runs only when no previous results exist or hardware has changed (GPU detection)
- Saves to `~/.config/VideoTools/benchmark.json` with last 10 runs in history
- No more redundant benchmarks every time you open the module
- ✅ **Merge Module Output Path UX Improvement**
- Split single output path field into separate folder and filename fields
- "Output Folder" field with "Browse Folder" button for directory selection
- "Output Filename" field for easy filename editing (e.g., "merged.mkv")
- No more navigating through long paths to change filenames
- Cleaner, more intuitive interface following standard file dialog patterns
- Auto-population sets directory and filename independently
- ✅ **Queue Priority System for Convert Now**
- "Convert Now" during active conversions adds job to top of queue (after running job)
- "Add to Queue" continues to add to end as expected
- Implemented AddNext() method in queue package for priority insertion
- User feedback message indicates queue position: "Added to top of queue!" vs "Conversion started!"
- Better workflow when adding files during active batch conversions
- ✅ **Auto-Cleanup for Failed Conversions**
- Convert jobs now automatically delete incomplete/broken output files on failure
- Success tracking ensures complete files are never removed
- Prevents accumulation of partial files from crashed/cancelled conversions
- Cleaner disk space management and error handling
- ✅ **Queue List Jankiness Reduction**
- Increased auto-refresh interval from 1000ms to 2000ms for smoother updates
- Reduced scroll restoration delay from 50ms to 10ms for faster position recovery
- Fixed race condition in scroll offset saving
- Eliminated visible jumping during queue view rebuilds
## Version 0.1.0-dev20+ (2025-12-28) - Queue UI Performance & Remux Safety
### Performance Optimizations
- ✅ **Queue View Button Responsiveness**

View File

@ -95,7 +95,7 @@ func (q *Queue) notifyChange() {
}
}
// Add adds a job to the queue (at the end)
// Add adds a job to the queue
func (q *Queue) Add(job *Job) {
q.mu.Lock()
@ -115,37 +115,6 @@ func (q *Queue) Add(job *Job) {
q.notifyChange()
}
// AddNext adds a job to the front of the pending queue (right after any running job)
func (q *Queue) AddNext(job *Job) {
q.mu.Lock()
if job.ID == "" {
job.ID = generateID()
}
if job.CreatedAt.IsZero() {
job.CreatedAt = time.Now()
}
if job.Status == "" {
job.Status = JobStatusPending
}
// Find the position after any running jobs
insertPos := 0
for i, j := range q.jobs {
if j.Status == JobStatusRunning {
insertPos = i + 1
} else {
break
}
}
// Insert at the calculated position
q.jobs = append(q.jobs[:insertPos], append([]*Job{job}, q.jobs[insertPos:]...)...)
q.rebalancePrioritiesLocked()
q.mu.Unlock()
q.notifyChange()
}
// Remove removes a job from the queue by ID
func (q *Queue) Remove(id string) error {
q.mu.Lock()

266
main.go
View File

@ -866,8 +866,7 @@ type appState struct {
// Merge state
mergeClips []mergeClip
mergeFormat string
mergeOutputDir string
mergeOutputFilename string
mergeOutput string
mergeKeepAll bool
mergeCodecMode string
mergeChapters bool
@ -1936,15 +1935,13 @@ func (s *appState) refreshQueueView() {
// Restore scroll offset
s.queueScroll = scroll
if s.queueScroll != nil && s.active == "queue" {
// Restore scroll position immediately to reduce jankiness
// Set offset before showing to avoid visible jumping
savedOffset := s.queueOffset
// Use ScrollTo instead of directly setting Offset to prevent rubber banding
// Defer to allow UI to settle first
go func() {
// Minimal delay to allow layout calculation
time.Sleep(10 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
if s.queueScroll != nil {
s.queueScroll.Offset = savedOffset
s.queueScroll.Offset = s.queueOffset
s.queueScroll.Refresh()
}
}, false)
@ -1965,10 +1962,9 @@ func (s *appState) startQueueAutoRefresh() {
s.queueAutoRefreshStop = stop
s.queueAutoRefreshRunning = true
go func() {
// Use 2-second interval to reduce UI jankiness from frequent rebuilds
// Slower refresh = smoother experience, especially with scroll position preservation
// Use 1-second interval to reduce UI update frequency, especially on Windows
// The refreshQueueView method has its own 500ms throttle for other triggers
ticker := time.NewTicker(2000 * time.Millisecond)
ticker := time.NewTicker(1000 * time.Millisecond)
defer ticker.Stop()
for {
select {
@ -2012,15 +2008,15 @@ func (s *appState) stopQueueAutoRefresh() {
}
// addConvertToQueue adds a conversion job to the queue
func (s *appState) addConvertToQueue(addToTop bool) error {
func (s *appState) addConvertToQueue() error {
if s.source == nil {
return fmt.Errorf("no video loaded")
}
return s.addConvertToQueueForSource(s.source, addToTop)
return s.addConvertToQueueForSource(s.source)
}
func (s *appState) addConvertToQueueForSource(src *videoSource, addToTop bool) error {
func (s *appState) addConvertToQueueForSource(src *videoSource) error {
outputBase := s.resolveOutputBase(src, true)
cfg := s.convert
cfg.OutputBase = outputBase
@ -2106,14 +2102,8 @@ func (s *appState) addConvertToQueueForSource(src *videoSource, addToTop bool) e
Config: config,
}
// Add to top (after running job) if requested and queue is running
if addToTop && s.jobQueue.IsRunning() {
s.jobQueue.AddNext(job)
logging.Debug(logging.CatSystem, "added convert job to top of queue: %s", job.ID)
} else {
s.jobQueue.Add(job)
logging.Debug(logging.CatSystem, "added convert job to queue: %s", job.ID)
}
s.jobQueue.Add(job)
logging.Debug(logging.CatSystem, "added convert job to queue: %s", job.ID)
return nil
}
@ -2251,80 +2241,6 @@ func (s *appState) showBenchmark() {
hwInfo := sysinfo.Detect()
logging.Debug(logging.CatSystem, "detected hardware for benchmark: %s", hwInfo.Summary())
// Check if we have recent benchmark results for this hardware
cfg, err := loadBenchmarkConfig()
if err == nil && len(cfg.History) > 0 {
lastRun := cfg.History[0]
// Check if hardware matches (same GPU)
hardwareMatches := lastRun.HardwareInfo.GPU == hwInfo.GPU
// If hardware matches, show last results instead of auto-running
if hardwareMatches && len(lastRun.Results) > 0 {
logging.Debug(logging.CatSystem, "found existing benchmark from %s, showing results", lastRun.Timestamp.Format("2006-01-02"))
// Create recommendation from saved data
rec := benchmark.Result{
Encoder: lastRun.RecommendedEncoder,
Preset: lastRun.RecommendedPreset,
FPS: lastRun.RecommendedFPS,
Score: lastRun.RecommendedFPS,
}
// Show results with "Run New Benchmark" option
resultsView := ui.BuildBenchmarkResultsView(
lastRun.Results,
rec,
lastRun.HardwareInfo,
func() {
// Apply recommended settings
s.applyBenchmarkRecommendation(lastRun.RecommendedEncoder, lastRun.RecommendedPreset)
s.showMainMenu()
},
func() {
// Close - go back to main menu
s.showMainMenu()
},
utils.MustHex("#4CE870"),
utils.MustHex("#1E1E1E"),
utils.MustHex("#FFFFFF"),
)
// Add "Run New Benchmark" button at the bottom
runNewBtn := widget.NewButton("Run New Benchmark", func() {
s.runNewBenchmark()
})
runNewBtn.Importance = widget.MediumImportance
cachedNote := widget.NewLabel(fmt.Sprintf("Showing cached results from %s", lastRun.Timestamp.Format("January 2, 2006 at 3:04 PM")))
cachedNote.Alignment = fyne.TextAlignCenter
cachedNote.TextStyle = fyne.TextStyle{Italic: true}
viewWithButton := container.NewBorder(
nil,
container.NewVBox(
widget.NewSeparator(),
cachedNote,
container.NewCenter(runNewBtn),
),
nil, nil,
resultsView,
)
s.setContent(viewWithButton)
return
}
}
// No existing benchmark or hardware changed - run new benchmark
s.runNewBenchmark()
}
func (s *appState) runNewBenchmark() {
// Detect hardware info upfront
hwInfo := sysinfo.Detect()
logging.Debug(logging.CatSystem, "starting new benchmark for hardware: %s", hwInfo.Summary())
// Create benchmark suite
tmpDir := filepath.Join(utils.TempDir(), "videotools-benchmark")
_ = os.MkdirAll(tmpDir, 0o755)
@ -2850,11 +2766,9 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.mergeClips = append(s.mergeClips, clips...)
if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutputDir) == "" {
s.mergeOutputDir = filepath.Dir(s.mergeClips[0].Path)
}
if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutputFilename) == "" {
s.mergeOutputFilename = "merged.mkv"
if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutput) == "" {
first := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(first, "merged.mkv")
}
s.showMergeView()
}, false)
@ -3247,11 +3161,9 @@ func (s *appState) showMergeView() {
Duration: src.Duration,
})
}
if len(s.mergeClips) >= 2 && s.mergeOutputDir == "" {
s.mergeOutputDir = filepath.Dir(s.mergeClips[0].Path)
}
if len(s.mergeClips) >= 2 && s.mergeOutputFilename == "" {
s.mergeOutputFilename = "merged.mkv"
if len(s.mergeClips) >= 2 && s.mergeOutput == "" {
first := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(first, "merged.mkv")
}
buildList()
}
@ -3317,40 +3229,31 @@ func (s *appState) showMergeView() {
})
chapterCheck.SetChecked(s.mergeChapters)
// Create output entry widgets first so they can be referenced in callbacks
outputDirEntry := widget.NewEntry()
outputDirEntry.SetPlaceHolder("Output folder path")
outputDirEntry.SetText(s.mergeOutputDir)
outputDirEntry.OnChanged = func(val string) {
s.mergeOutputDir = val
}
outputFilenameEntry := widget.NewEntry()
outputFilenameEntry.SetPlaceHolder("merged.mkv")
outputFilenameEntry.SetText(s.mergeOutputFilename)
outputFilenameEntry.OnChanged = func(val string) {
s.mergeOutputFilename = val
// Create output entry widget first so it can be referenced in callbacks
outputEntry := widget.NewEntry()
outputEntry.SetPlaceHolder("merged output path")
outputEntry.SetText(s.mergeOutput)
outputEntry.OnChanged = func(val string) {
s.mergeOutput = val
}
clearBtn := widget.NewButton("Clear", func() {
s.mergeClips = nil
s.mergeOutputDir = ""
s.mergeOutputFilename = ""
outputDirEntry.SetText("")
outputFilenameEntry.SetText("")
s.mergeOutput = ""
outputEntry.SetText("")
buildList()
})
// Helper to update output filename extension (requires outputFilenameEntry to exist)
// Helper to update output path extension (requires outputEntry to exist)
updateOutputExt := func() {
if s.mergeOutputFilename == "" {
if s.mergeOutput == "" {
return
}
currentExt := filepath.Ext(s.mergeOutputFilename)
currentExt := filepath.Ext(s.mergeOutput)
correctExt := getExtForFormat(s.mergeFormat)
if currentExt != correctExt {
s.mergeOutputFilename = strings.TrimSuffix(s.mergeOutputFilename, currentExt) + correctExt
outputFilenameEntry.SetText(s.mergeOutputFilename)
s.mergeOutput = strings.TrimSuffix(s.mergeOutput, currentExt) + correctExt
outputEntry.SetText(s.mergeOutput)
}
}
@ -3388,14 +3291,9 @@ func (s *appState) showMergeView() {
dvdOptionsContainer.Hide()
}
// Set default output directory if not set
if s.mergeOutputDir == "" && len(s.mergeClips) > 0 {
s.mergeOutputDir = filepath.Dir(s.mergeClips[0].Path)
outputDirEntry.SetText(s.mergeOutputDir)
}
// Set default output filename if not set
if s.mergeOutputFilename == "" && len(s.mergeClips) > 0 {
// Set default output path if not set
if s.mergeOutput == "" && len(s.mergeClips) > 0 {
dir := filepath.Dir(s.mergeClips[0].Path)
ext := getExtForFormat(s.mergeFormat)
basename := "merged"
if strings.HasPrefix(s.mergeFormat, "dvd") || s.mergeFormat == "dvd" {
@ -3405,10 +3303,10 @@ func (s *appState) showMergeView() {
} else if s.mergeFormat == "mkv-lossless" {
basename = "merged-lossless"
}
s.mergeOutputFilename = basename + ext
outputFilenameEntry.SetText(s.mergeOutputFilename)
s.mergeOutput = filepath.Join(dir, basename+ext)
outputEntry.SetText(s.mergeOutput)
} else {
// Update extension of existing filename
// Update extension of existing path
updateOutputExt()
}
s.persistMergeConfig()
@ -3446,13 +3344,14 @@ func (s *appState) showMergeView() {
motionInterpCheck,
)
browseDirBtn := widget.NewButton("Browse Folder", func() {
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
browseOut := widget.NewButton("Browse", func() {
dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) {
if err != nil || writer == nil {
return
}
s.mergeOutputDir = uri.Path()
outputDirEntry.SetText(s.mergeOutputDir)
s.mergeOutput = writer.URI().Path()
outputEntry.SetText(s.mergeOutput)
writer.Close()
}, s.window)
})
@ -3578,10 +3477,8 @@ func (s *appState) showMergeView() {
keepAllCheck,
chapterCheck,
widget.NewSeparator(),
widget.NewLabel("Output Folder"),
container.NewBorder(nil, nil, nil, browseDirBtn, outputDirEntry),
widget.NewLabel("Output Filename"),
outputFilenameEntry,
widget.NewLabel("Output Path"),
container.NewBorder(nil, nil, nil, browseOut, outputEntry),
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
widget.NewSeparator(),
@ -3599,17 +3496,13 @@ func (s *appState) addMergeToQueue(startNow bool) error {
if len(s.mergeClips) < 2 {
return fmt.Errorf("add at least two clips")
}
// Set defaults if not specified
if strings.TrimSpace(s.mergeOutputDir) == "" {
s.mergeOutputDir = filepath.Dir(s.mergeClips[0].Path)
}
if strings.TrimSpace(s.mergeOutputFilename) == "" {
s.mergeOutputFilename = "merged.mkv"
if strings.TrimSpace(s.mergeOutput) == "" {
firstDir := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(firstDir, "merged.mkv")
}
// Ensure output filename has correct extension for selected format
currentExt := filepath.Ext(s.mergeOutputFilename)
// Ensure output path has correct extension for selected format
currentExt := filepath.Ext(s.mergeOutput)
var correctExt string
switch {
case strings.HasPrefix(s.mergeFormat, "dvd"):
@ -3628,13 +3521,10 @@ func (s *appState) addMergeToQueue(startNow bool) error {
// Auto-fix extension if missing or wrong
if currentExt == "" {
s.mergeOutputFilename += correctExt
s.mergeOutput += correctExt
} else if currentExt != correctExt {
s.mergeOutputFilename = strings.TrimSuffix(s.mergeOutputFilename, currentExt) + correctExt
s.mergeOutput = strings.TrimSuffix(s.mergeOutput, currentExt) + correctExt
}
// Combine dir and filename to create full output path
mergeOutput := filepath.Join(s.mergeOutputDir, s.mergeOutputFilename)
clips := make([]map[string]interface{}, 0, len(s.mergeClips))
for _, c := range s.mergeClips {
name := c.Chapter
@ -3654,7 +3544,7 @@ func (s *appState) addMergeToQueue(startNow bool) error {
"keepAllStreams": s.mergeKeepAll,
"chapters": s.mergeChapters,
"codecMode": s.mergeCodecMode,
"outputPath": mergeOutput,
"outputPath": s.mergeOutput,
"dvdRegion": s.mergeDVDRegion,
"dvdAspect": s.mergeDVDAspect,
"frameRate": s.mergeFrameRate,
@ -3664,9 +3554,9 @@ func (s *appState) addMergeToQueue(startNow bool) error {
job := &queue.Job{
Type: queue.JobTypeMerge,
Title: fmt.Sprintf("Merge %d clips", len(clips)),
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(mergeOutput), 40)),
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(s.mergeOutput), 40)),
InputFile: clips[0]["path"].(string),
OutputFile: mergeOutput,
OutputFile: s.mergeOutput,
Config: config,
}
s.jobQueue.Add(job)
@ -4074,18 +3964,6 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
inputPath := cfg["inputPath"].(string)
outputPath := cfg["outputPath"].(string)
// Track success to clean up broken files on failure
var success bool
defer func() {
if !success && outputPath != "" {
// Remove incomplete/broken output file on failure
if _, err := os.Stat(outputPath); err == nil {
logging.Debug(logging.CatFFMPEG, "removing incomplete output file: %s", outputPath)
os.Remove(outputPath)
}
}
}()
// If a direct conversion is running, wait until it finishes before starting queued jobs.
for s.convertBusy {
select {
@ -4798,8 +4676,6 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
}()
}
// Mark as successful to prevent cleanup of output file
success = true
return nil
}
@ -6173,8 +6049,7 @@ func runLogsCLI() error {
}
func (s *appState) executeAddToQueue() {
// Add to end of queue
if err := s.addConvertToQueue(false); err != nil {
if err := s.addConvertToQueue(); err != nil {
dialog.ShowError(err, s.window)
} else {
// Update queue button to show new count
@ -6204,8 +6079,8 @@ func (s *appState) executeAddAllToQueue() {
}
func (s *appState) executeConversion() {
// Add job to queue (at top if queue is already running)
if err := s.addConvertToQueue(true); err != nil {
// Add job to queue and start immediately
if err := s.addConvertToQueue(); err != nil {
dialog.ShowError(err, s.window)
return
}
@ -6220,11 +6095,7 @@ func (s *appState) executeConversion() {
s.clearVideo()
// Show success message
if s.jobQueue != nil && s.jobQueue.IsRunning() {
dialog.ShowInformation("Convert", "Added to top of queue! View progress in Job Queue.", s.window)
} else {
dialog.ShowInformation("Convert", "Conversion started! View progress in Job Queue.", s.window)
}
dialog.ShowInformation("Convert", "Conversion started! View progress in Job Queue.", s.window)
}
func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
@ -6965,9 +6836,6 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
// Settings management for batch operations
settingsInfoLabel := widget.NewLabel("Settings persist across videos. Change them anytime to affect all subsequent videos.")
settingsInfoLabel.Alignment = fyne.TextAlignCenter
settingsInfoLabel.Wrapping = fyne.TextWrapWord
// Wrap in padded container for proper text wrapping in narrow windows
settingsInfoContainer := container.NewPadded(settingsInfoLabel)
cacheDirLabel := widget.NewLabelWithStyle("Cache/Temp Directory", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
cacheDirEntry := widget.NewEntry()
@ -6975,8 +6843,6 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
cacheDirEntry.SetText(state.convert.TempDir)
cacheDirHint := widget.NewLabel("Use an SSD for best performance. Leave blank to use system temp.")
cacheDirHint.Wrapping = fyne.TextWrapWord
// Wrap in padded container for proper text wrapping in narrow windows
cacheDirHintContainer := container.NewPadded(cacheDirHint)
cacheDirEntry.OnChanged = func(val string) {
state.convert.TempDir = strings.TrimSpace(val)
utils.SetTempDir(state.convert.TempDir)
@ -7006,12 +6872,12 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
resetSettingsBtn.Importance = widget.LowImportance
settingsContent := container.NewVBox(
settingsInfoContainer,
settingsInfoLabel,
widget.NewSeparator(),
cacheDirLabel,
container.NewBorder(nil, nil, nil, cacheBrowseBtn, cacheDirEntry),
cacheUseSystemBtn,
cacheDirHintContainer,
cacheDirHint,
resetSettingsBtn,
)
settingsContent.Hide()
@ -10747,12 +10613,10 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
Duration: src.Duration,
})
// Set default output dir and filename if not set and we have at least 2 clips
if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutputDir) == "" {
s.mergeOutputDir = filepath.Dir(s.mergeClips[0].Path)
}
if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutputFilename) == "" {
s.mergeOutputFilename = "merged.mkv"
// Set default output path if not set and we have at least 2 clips
if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutput) == "" {
first := filepath.Dir(s.mergeClips[0].Path)
s.mergeOutput = filepath.Join(first, "merged.mkv")
}
// Refresh the merge view to show the new clips