Refactor Compare module with auto-loading and thumbnails

Major improvements to Compare module user experience:

- Auto-populate metadata when files are loaded (no Compare button needed)
- Show video thumbnails for both files (320x180)
- Support drag-and-drop onto Compare tile from main menu
- Load up to 2 videos when dropped on Compare tile
- Show dialog if more than 2 videos dropped
- Files loaded via drag show immediately with metadata

Changes to handleModuleDrop:
- Added special handling for Compare module
- Loads videos into compareFile1 and compareFile2 state
- Shows module with files already populated

Changes to buildCompareView:
- Added thumbnail display with dark background placeholders
- Created helper functions: formatMetadata(), loadThumbnail(), updateFile1(), updateFile2()
- Initialize view with any preloaded files
- Removed manual Compare button - metadata shows automatically
- Button handlers now call update functions to refresh display
- Cleaner, more intuitive workflow

This addresses the user feedback that dragging videos onto Compare
didn't load the module, and adds the requested thumbnail previews.
This commit is contained in:
Stu Leak 2025-12-04 01:39:32 -05:00
parent 1e49fd2f05
commit 6990f18829

372
main.go
View File

@ -703,6 +703,49 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
return return
} }
// If compare module, load up to 2 videos into compare slots
if moduleID == "compare" {
go func() {
// Load first video
src1, err := probeVideo(videoPaths[0])
if err != nil {
logging.Debug(logging.CatModule, "failed to load first video for compare: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
}, false)
return
}
// Load second video if available
var src2 *videoSource
if len(videoPaths) >= 2 {
src2, err = probeVideo(videoPaths[1])
if err != nil {
logging.Debug(logging.CatModule, "failed to load second video for compare: %v", err)
// Continue with just first video
}
}
// Show dialog if more than 2 videos
if len(videoPaths) > 2 {
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowInformation("Compare Videos",
fmt.Sprintf("You dropped %d videos. Only the first two will be loaded for comparison.", len(videoPaths)),
s.window)
}, false)
}
// Update state and show module
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.compareFile1 = src1
s.compareFile2 = src2
s.showModule(moduleID)
logging.Debug(logging.CatModule, "loaded %d video(s) for compare module", len(videoPaths))
}, false)
}()
return
}
// Single file or non-convert module: load first video and show module // Single file or non-convert module: load first video and show module
path := videoPaths[0] path := videoPaths[0]
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path) logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
@ -5297,206 +5340,193 @@ func buildCompareView(state *appState) fyne.CanvasObject {
topBar := ui.TintedBar(compareColor, container.NewHBox(backBtn, layout.NewSpacer())) topBar := ui.TintedBar(compareColor, container.NewHBox(backBtn, layout.NewSpacer()))
// Instructions // Instructions
instructions := widget.NewLabel("Load two videos to compare their metadata and visual differences side by side. Drag videos here or use buttons below.") instructions := widget.NewLabel("Load two videos to compare their metadata side by side. Drag videos here or use buttons below.")
instructions.Wrapping = fyne.TextWrapWord instructions.Wrapping = fyne.TextWrapWord
instructions.Alignment = fyne.TextAlignCenter instructions.Alignment = fyne.TextAlignCenter
// File labels (declare early for use in helper) // File labels
file1Label := widget.NewLabel("File 1: Not loaded") file1Label := widget.NewLabel("File 1: Not loaded")
file1Label.TextStyle = fyne.TextStyle{Bold: true} file1Label.TextStyle = fyne.TextStyle{Bold: true}
file2Label := widget.NewLabel("File 2: Not loaded") file2Label := widget.NewLabel("File 2: Not loaded")
file2Label.TextStyle = fyne.TextStyle{Bold: true} file2Label.TextStyle = fyne.TextStyle{Bold: true}
file1SelectBtn := widget.NewButton("Load File 1", func() { // Thumbnail images
// File picker for first file file1Thumbnail := canvas.NewImageFromImage(nil)
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { file1Thumbnail.FillMode = canvas.ImageFillContain
if err != nil || reader == nil { file1Thumbnail.SetMinSize(fyne.NewSize(320, 180))
return
}
path := reader.URI().Path()
reader.Close()
// Probe the video file2Thumbnail := canvas.NewImageFromImage(nil)
src, err := probeVideo(path) file2Thumbnail.FillMode = canvas.ImageFillContain
if err != nil { file2Thumbnail.SetMinSize(fyne.NewSize(320, 180))
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
file1Label.SetText(fmt.Sprintf("File 1: %s", filepath.Base(path))) // Placeholder backgrounds
state.compareFile1 = src file1ThumbBg := canvas.NewRectangle(utils.MustHex("#0F1529"))
logging.Debug(logging.CatModule, "loaded compare file 1: %s", path) file1ThumbBg.SetMinSize(fyne.NewSize(320, 180))
}, state.window)
})
file2ThumbBg := canvas.NewRectangle(utils.MustHex("#0F1529"))
file2ThumbBg.SetMinSize(fyne.NewSize(320, 180))
// Info labels
file1Info := widget.NewLabel("No file loaded") file1Info := widget.NewLabel("No file loaded")
file1Info.Wrapping = fyne.TextWrapWord file1Info.Wrapping = fyne.TextWrapWord
// File 2 button (label already declared earlier)
file2SelectBtn := widget.NewButton("Load File 2", func() {
// File picker for second file
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
path := reader.URI().Path()
reader.Close()
// Probe the video
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
file2Label.SetText(fmt.Sprintf("File 2: %s", filepath.Base(path)))
state.compareFile2 = src
logging.Debug(logging.CatModule, "loaded compare file 2: %s", path)
}, state.window)
})
file2Info := widget.NewLabel("No file loaded") file2Info := widget.NewLabel("No file loaded")
file2Info.Wrapping = fyne.TextWrapWord file2Info.Wrapping = fyne.TextWrapWord
// Compare button // Helper function to format metadata
compareBtn := widget.NewButton("COMPARE", func() { formatMetadata := func(src *videoSource) string {
if state.compareFile1 == nil || state.compareFile2 == nil { fileSize := "Unknown"
dialog.ShowInformation("Compare Videos", "Please load both files first.", state.window) if fi, err := os.Stat(src.Path); err == nil {
sizeMB := float64(fi.Size()) / (1024 * 1024)
if sizeMB >= 1024 {
fileSize = fmt.Sprintf("%.2f GB", sizeMB/1024)
} else {
fileSize = fmt.Sprintf("%.2f MB", sizeMB)
}
}
return fmt.Sprintf(
"━━━ FILE INFO ━━━\n"+
"Path: %s\n"+
"File Size: %s\n"+
"Format: %s\n"+
"\n━━━ VIDEO ━━━\n"+
"Codec: %s\n"+
"Resolution: %dx%d\n"+
"Aspect Ratio: %s\n"+
"Frame Rate: %.2f fps\n"+
"Bitrate: %s\n"+
"Pixel Format: %s\n"+
"Color Space: %s\n"+
"Color Range: %s\n"+
"Field Order: %s\n"+
"GOP Size: %d\n"+
"\n━━━ AUDIO ━━━\n"+
"Codec: %s\n"+
"Bitrate: %s\n"+
"Sample Rate: %d Hz\n"+
"Channels: %d\n"+
"\n━━━ OTHER ━━━\n"+
"Duration: %s\n"+
"SAR (Pixel Aspect): %s\n"+
"Chapters: %v\n"+
"Metadata: %v",
filepath.Base(src.Path),
fileSize,
src.Format,
src.VideoCodec,
src.Width, src.Height,
src.AspectRatioString(),
src.FrameRate,
formatBitrate(src.Bitrate),
src.PixelFormat,
src.ColorSpace,
src.ColorRange,
src.FieldOrder,
src.GOPSize,
src.AudioCodec,
formatBitrate(src.AudioBitrate),
src.AudioRate,
src.Channels,
src.DurationString(),
src.SampleAspectRatio,
src.HasChapters,
src.HasMetadata,
)
}
// Helper to load thumbnail for a video
loadThumbnail := func(src *videoSource, img *canvas.Image) {
if src == nil || len(src.PreviewFrames) == 0 {
return return
} }
// Load the first preview frame as thumbnail
// Build comparison data thumbImg := canvas.NewImageFromFile(src.PreviewFrames[0])
f1 := state.compareFile1 if thumbImg.Image != nil {
f2 := state.compareFile2 img.Image = thumbImg.Image
img.Refresh()
// Calculate file size
file1Size := "Unknown"
if fi, err := os.Stat(f1.Path); err == nil {
sizeMB := float64(fi.Size()) / (1024 * 1024)
if sizeMB >= 1024 {
file1Size = fmt.Sprintf("%.2f GB", sizeMB/1024)
} else {
file1Size = fmt.Sprintf("%.2f MB", sizeMB)
}
} }
}
file1Info.SetText(fmt.Sprintf( // Helper to update file display
"━━━ FILE INFO ━━━\n"+ updateFile1 := func() {
"Path: %s\n"+ if state.compareFile1 != nil {
"File Size: %s\n"+ file1Label.SetText(fmt.Sprintf("File 1: %s", filepath.Base(state.compareFile1.Path)))
"Format: %s\n"+ file1Info.SetText(formatMetadata(state.compareFile1))
"\n━━━ VIDEO ━━━\n"+ loadThumbnail(state.compareFile1, file1Thumbnail)
"Codec: %s\n"+ } else {
"Resolution: %dx%d\n"+ file1Label.SetText("File 1: Not loaded")
"Aspect Ratio: %s\n"+ file1Info.SetText("No file loaded")
"Frame Rate: %.2f fps\n"+ file1Thumbnail.Image = nil
"Bitrate: %s\n"+ file1Thumbnail.Refresh()
"Pixel Format: %s\n"+
"Color Space: %s\n"+
"Color Range: %s\n"+
"Field Order: %s\n"+
"GOP Size: %d\n"+
"\n━━━ AUDIO ━━━\n"+
"Codec: %s\n"+
"Bitrate: %s\n"+
"Sample Rate: %d Hz\n"+
"Channels: %d\n"+
"\n━━━ OTHER ━━━\n"+
"Duration: %s\n"+
"SAR (Pixel Aspect): %s\n"+
"Chapters: %v\n"+
"Metadata: %v",
filepath.Base(f1.Path),
file1Size,
f1.Format,
f1.VideoCodec,
f1.Width, f1.Height,
f1.AspectRatioString(),
f1.FrameRate,
formatBitrate(f1.Bitrate),
f1.PixelFormat,
f1.ColorSpace,
f1.ColorRange,
f1.FieldOrder,
f1.GOPSize,
f1.AudioCodec,
formatBitrate(f1.AudioBitrate),
f1.AudioRate,
f1.Channels,
f1.DurationString(),
f1.SampleAspectRatio,
f1.HasChapters,
f1.HasMetadata,
))
// Calculate file size
file2Size := "Unknown"
if fi, err := os.Stat(f2.Path); err == nil {
sizeMB := float64(fi.Size()) / (1024 * 1024)
if sizeMB >= 1024 {
file2Size = fmt.Sprintf("%.2f GB", sizeMB/1024)
} else {
file2Size = fmt.Sprintf("%.2f MB", sizeMB)
}
} }
}
file2Info.SetText(fmt.Sprintf( updateFile2 := func() {
"━━━ FILE INFO ━━━\n"+ if state.compareFile2 != nil {
"Path: %s\n"+ file2Label.SetText(fmt.Sprintf("File 2: %s", filepath.Base(state.compareFile2.Path)))
"File Size: %s\n"+ file2Info.SetText(formatMetadata(state.compareFile2))
"Format: %s\n"+ loadThumbnail(state.compareFile2, file2Thumbnail)
"\n━━━ VIDEO ━━━\n"+ } else {
"Codec: %s\n"+ file2Label.SetText("File 2: Not loaded")
"Resolution: %dx%d\n"+ file2Info.SetText("No file loaded")
"Aspect Ratio: %s\n"+ file2Thumbnail.Image = nil
"Frame Rate: %.2f fps\n"+ file2Thumbnail.Refresh()
"Bitrate: %s\n"+ }
"Pixel Format: %s\n"+ }
"Color Space: %s\n"+
"Color Range: %s\n"+ // Initialize with any already-loaded files
"Field Order: %s\n"+ updateFile1()
"GOP Size: %d\n"+ updateFile2()
"\n━━━ AUDIO ━━━\n"+
"Codec: %s\n"+ file1SelectBtn := widget.NewButton("Load File 1", func() {
"Bitrate: %s\n"+ dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
"Sample Rate: %d Hz\n"+ if err != nil || reader == nil {
"Channels: %d\n"+ return
"\n━━━ OTHER ━━━\n"+ }
"Duration: %s\n"+ path := reader.URI().Path()
"SAR (Pixel Aspect): %s\n"+ reader.Close()
"Chapters: %v\n"+
"Metadata: %v", src, err := probeVideo(path)
filepath.Base(f2.Path), if err != nil {
file2Size, dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
f2.Format, return
f2.VideoCodec, }
f2.Width, f2.Height,
f2.AspectRatioString(), state.compareFile1 = src
f2.FrameRate, updateFile1()
formatBitrate(f2.Bitrate), logging.Debug(logging.CatModule, "loaded compare file 1: %s", path)
f2.PixelFormat, }, state.window)
f2.ColorSpace, })
f2.ColorRange,
f2.FieldOrder, file2SelectBtn := widget.NewButton("Load File 2", func() {
f2.GOPSize, dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
f2.AudioCodec, if err != nil || reader == nil {
formatBitrate(f2.AudioBitrate), return
f2.AudioRate, }
f2.Channels, path := reader.URI().Path()
f2.DurationString(), reader.Close()
f2.SampleAspectRatio,
f2.HasChapters, src, err := probeVideo(path)
f2.HasMetadata, if err != nil {
)) dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
state.compareFile2 = src
updateFile2()
logging.Debug(logging.CatModule, "loaded compare file 2: %s", path)
}, state.window)
}) })
compareBtn.Importance = widget.HighImportance
// Layout // Layout
file1Box := container.NewVBox( file1Box := container.NewVBox(
file1Label, file1Label,
file1SelectBtn, file1SelectBtn,
widget.NewSeparator(), widget.NewSeparator(),
container.NewMax(file1ThumbBg, file1Thumbnail),
widget.NewSeparator(),
container.NewScroll(file1Info), container.NewScroll(file1Info),
) )
@ -5504,6 +5534,8 @@ func buildCompareView(state *appState) fyne.CanvasObject {
file2Label, file2Label,
file2SelectBtn, file2SelectBtn,
widget.NewSeparator(), widget.NewSeparator(),
container.NewMax(file2ThumbBg, file2Thumbnail),
widget.NewSeparator(),
container.NewScroll(file2Info), container.NewScroll(file2Info),
) )
@ -5514,8 +5546,6 @@ func buildCompareView(state *appState) fyne.CanvasObject {
content := container.NewVBox( content := container.NewVBox(
instructions, instructions,
widget.NewSeparator(), widget.NewSeparator(),
compareBtn,
widget.NewSeparator(),
container.NewGridWithColumns(2, container.NewGridWithColumns(2,
file1Box, file1Box,
file2Box, file2Box,