VideoTools/thumb_module.go
Stu Leak de81c9f999 Add clear completed button to all module top bars
- Added clearCompletedJobs() method to appState in main.go
- Clears only completed and failed jobs, keeps pending/running/paused
- Added small ⌫ (backspace) icon button next to View Queue in all modules
- Button has LowImportance styling to keep it subtle
- Implements Jake's suggestion for quick queue cleanup

Modules updated:
- Convert (main.go)
- Author (author_module.go)
- Subtitles (subtitles_module.go)
- Rip (rip_module.go)
- Filters (filters_module.go)
- Thumbnails (thumb_module.go)
- Inspect (inspect_module.go)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-29 22:02:35 -05:00

413 lines
12 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
"git.leaktechnologies.dev/stu/VideoTools/internal/thumbnail"
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
)
func (s *appState) showThumbView() {
s.stopPreview()
s.lastModule = s.active
s.active = "thumb"
s.setContent(buildThumbView(s))
}
func buildThumbView(state *appState) fyne.CanvasObject {
thumbColor := moduleColor("thumb")
// Back button
backBtn := widget.NewButton("< THUMBNAILS", func() {
state.showMainMenu()
})
backBtn.Importance = widget.LowImportance
// Top bar with module color
queueBtn := widget.NewButton("View Queue", func() {
state.showQueue()
})
state.queueBtn = queueBtn
state.updateQueueButtonLabel()
clearCompletedBtn := widget.NewButton("⌫", func() {
state.clearCompletedJobs()
})
clearCompletedBtn.Importance = widget.LowImportance
topBar := ui.TintedBar(thumbColor, container.NewHBox(backBtn, layout.NewSpacer(), clearCompletedBtn, queueBtn))
// Instructions
instructions := widget.NewLabel("Generate thumbnails from a video file. Load a video and configure settings.")
instructions.Wrapping = fyne.TextWrapWord
instructions.Alignment = fyne.TextAlignCenter
// Initialize state defaults
if state.thumbCount == 0 {
state.thumbCount = 24 // Default to 24 thumbnails (good for contact sheets)
}
if state.thumbWidth == 0 {
state.thumbWidth = 320
}
if state.thumbColumns == 0 {
state.thumbColumns = 4 // 4 columns works well for widescreen videos
}
if state.thumbRows == 0 {
state.thumbRows = 6 // 4x6 = 24 thumbnails
}
// File label and video preview
fileLabel := widget.NewLabel("No file loaded")
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
var videoContainer fyne.CanvasObject
if state.thumbFile != nil {
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbFile.Path)))
videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.thumbFile, nil)
} else {
videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
}
// Load button
loadBtn := widget.NewButton("Load Video", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
path := reader.URI().Path()
reader.Close()
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
state.thumbFile = src
state.showThumbView()
logging.Debug(logging.CatModule, "loaded thumbnail file: %s", path)
}, state.window)
})
// Clear button
clearBtn := widget.NewButton("Clear", func() {
state.thumbFile = nil
state.showThumbView()
})
clearBtn.Importance = widget.LowImportance
// Contact sheet checkbox
contactSheetCheck := widget.NewCheck("Generate Contact Sheet (single image)", func(checked bool) {
state.thumbContactSheet = checked
state.showThumbView()
})
contactSheetCheck.Checked = state.thumbContactSheet
// Conditional settings based on contact sheet mode
var settingsOptions fyne.CanvasObject
if state.thumbContactSheet {
// Contact sheet mode: show columns and rows
colLabel := widget.NewLabel(fmt.Sprintf("Columns: %d", state.thumbColumns))
rowLabel := widget.NewLabel(fmt.Sprintf("Rows: %d", state.thumbRows))
totalThumbs := state.thumbColumns * state.thumbRows
totalLabel := widget.NewLabel(fmt.Sprintf("Total thumbnails: %d", totalThumbs))
totalLabel.TextStyle = fyne.TextStyle{Italic: true}
colSlider := widget.NewSlider(2, 12)
colSlider.Value = float64(state.thumbColumns)
colSlider.Step = 1
colSlider.OnChanged = func(val float64) {
state.thumbColumns = int(val)
colLabel.SetText(fmt.Sprintf("Columns: %d", int(val)))
totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows))
}
rowSlider := widget.NewSlider(2, 12)
rowSlider.Value = float64(state.thumbRows)
rowSlider.Step = 1
rowSlider.OnChanged = func(val float64) {
state.thumbRows = int(val)
rowLabel.SetText(fmt.Sprintf("Rows: %d", int(val)))
totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows))
}
settingsOptions = container.NewVBox(
widget.NewSeparator(),
widget.NewLabel("Contact Sheet Grid:"),
colLabel,
colSlider,
rowLabel,
rowSlider,
totalLabel,
)
} else {
// Individual thumbnails mode: show count and width
countLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Count: %d", state.thumbCount))
countSlider := widget.NewSlider(3, 50)
countSlider.Value = float64(state.thumbCount)
countSlider.Step = 1
countSlider.OnChanged = func(val float64) {
state.thumbCount = int(val)
countLabel.SetText(fmt.Sprintf("Thumbnail Count: %d", int(val)))
}
widthLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Width: %d px", state.thumbWidth))
widthSlider := widget.NewSlider(160, 640)
widthSlider.Value = float64(state.thumbWidth)
widthSlider.Step = 32
widthSlider.OnChanged = func(val float64) {
state.thumbWidth = int(val)
widthLabel.SetText(fmt.Sprintf("Thumbnail Width: %d px", int(val)))
}
settingsOptions = container.NewVBox(
widget.NewSeparator(),
widget.NewLabel("Individual Thumbnails:"),
countLabel,
countSlider,
widthLabel,
widthSlider,
)
}
// Helper function to create thumbnail job
createThumbJob := func() *queue.Job {
// Create output directory in same folder as video
videoDir := filepath.Dir(state.thumbFile.Path)
videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path))
outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName))
// Configure based on mode
var count, width int
var description string
if state.thumbContactSheet {
// Contact sheet: count is determined by grid, use larger width for analyzable screenshots
count = state.thumbColumns * state.thumbRows
width = 280 // Larger width for contact sheets to make screenshots analyzable (4x8 grid = ~1144x1416)
description = fmt.Sprintf("Contact sheet: %dx%d grid (%d thumbnails)", state.thumbColumns, state.thumbRows, count)
} else {
// Individual thumbnails: use user settings
count = state.thumbCount
width = state.thumbWidth
description = fmt.Sprintf("%d individual thumbnails (%dpx width)", count, width)
}
return &queue.Job{
Type: queue.JobTypeThumb,
Title: "Thumbnails: " + filepath.Base(state.thumbFile.Path),
Description: description,
InputFile: state.thumbFile.Path,
OutputFile: outputDir,
Config: map[string]interface{}{
"inputPath": state.thumbFile.Path,
"outputDir": outputDir,
"count": float64(count),
"width": float64(width),
"contactSheet": state.thumbContactSheet,
"columns": float64(state.thumbColumns),
"rows": float64(state.thumbRows),
},
}
}
// Generate Now button - adds to queue and starts it
generateNowBtn := widget.NewButton("GENERATE NOW", func() {
if state.thumbFile == nil {
dialog.ShowInformation("No Video", "Please load a video file first.", state.window)
return
}
if state.jobQueue == nil {
dialog.ShowInformation("Queue", "Queue not initialized.", state.window)
return
}
job := createThumbJob()
state.jobQueue.Add(job)
// Start queue if not already running
if !state.jobQueue.IsRunning() {
state.jobQueue.Start()
logging.Debug(logging.CatSystem, "started queue from Generate Now")
}
dialog.ShowInformation("Thumbnails", "Thumbnail generation started! View progress in Job Queue.", state.window)
})
generateNowBtn.Importance = widget.HighImportance
if state.thumbFile == nil {
generateNowBtn.Disable()
}
// Add to Queue button
addQueueBtn := widget.NewButton("Add to Queue", func() {
if state.thumbFile == nil {
dialog.ShowInformation("No Video", "Please load a video file first.", state.window)
return
}
if state.jobQueue == nil {
dialog.ShowInformation("Queue", "Queue not initialized.", state.window)
return
}
job := createThumbJob()
state.jobQueue.Add(job)
dialog.ShowInformation("Queue", "Thumbnail job added to queue!", state.window)
})
addQueueBtn.Importance = widget.MediumImportance
if state.thumbFile == nil {
addQueueBtn.Disable()
}
// View Queue button
viewQueueBtn := widget.NewButton("View Queue", func() {
state.showQueue()
})
viewQueueBtn.Importance = widget.MediumImportance
// View Results button - shows output folder if it exists
viewResultsBtn := widget.NewButton("View Results", func() {
if state.thumbFile == nil {
dialog.ShowInformation("No Video", "Load a video first to locate results.", state.window)
return
}
videoDir := filepath.Dir(state.thumbFile.Path)
videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path))
outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName))
// Check if output exists
if _, err := os.Stat(outputDir); os.IsNotExist(err) {
dialog.ShowInformation("No Results", "No generated thumbnails found. Generate thumbnails first.", state.window)
return
}
// If contact sheet mode, try to show contact sheet image
if state.thumbContactSheet {
contactSheetPath := filepath.Join(outputDir, "contact_sheet.jpg")
if _, err := os.Stat(contactSheetPath); err == nil {
// Show contact sheet in a dialog
go func() {
img := canvas.NewImageFromFile(contactSheetPath)
img.FillMode = canvas.ImageFillContain
// Adaptive size for small screens - use scrollable dialog
img.SetMinSize(fyne.NewSize(640, 480))
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
// Wrap in scroll container for large contact sheets
scroll := container.NewScroll(img)
d := dialog.NewCustom("Contact Sheet", "Close", scroll, state.window)
// Adaptive dialog size that fits on 1280x768 screens
d.Resize(fyne.NewSize(700, 600))
d.Show()
}, false)
}()
return
}
}
// Otherwise, open folder
openFolder(outputDir)
})
viewResultsBtn.Importance = widget.MediumImportance
if state.thumbFile == nil {
viewResultsBtn.Disable()
}
// Settings panel
settingsPanel := container.NewVBox(
widget.NewLabel("Settings:"),
widget.NewSeparator(),
contactSheetCheck,
settingsOptions,
widget.NewSeparator(),
generateNowBtn,
addQueueBtn,
viewQueueBtn,
viewResultsBtn,
)
// Main content - split layout with preview on left, settings on right
leftColumn := container.NewVBox(
videoContainer,
)
rightColumn := container.NewVBox(
settingsPanel,
)
mainContent := container.New(&fixedHSplitLayout{ratio: 0.6}, leftColumn, rightColumn)
content := container.NewBorder(
container.NewVBox(instructions, widget.NewSeparator(), fileLabel, container.NewHBox(loadBtn, clearBtn)),
nil,
nil,
nil,
mainContent,
)
bottomBar := moduleFooter(thumbColor, layout.NewSpacer(), state.statsBar)
return container.NewBorder(topBar, bottomBar, nil, nil, content)
}
func (s *appState) executeThumbJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config
inputPath := cfg["inputPath"].(string)
outputDir := cfg["outputDir"].(string)
count := int(cfg["count"].(float64))
width := int(cfg["width"].(float64))
contactSheet := cfg["contactSheet"].(bool)
columns := int(cfg["columns"].(float64))
rows := int(cfg["rows"].(float64))
if progressCallback != nil {
progressCallback(0)
}
generator := thumbnail.NewGenerator(platformConfig.FFmpegPath)
config := thumbnail.Config{
VideoPath: inputPath,
OutputDir: outputDir,
Count: count,
Width: width,
Format: "jpg",
Quality: 85,
ContactSheet: contactSheet,
Columns: columns,
Rows: rows,
ShowTimestamp: false, // Disabled to avoid font issues
ShowMetadata: contactSheet,
}
result, err := generator.Generate(ctx, config)
if err != nil {
return fmt.Errorf("thumbnail generation failed: %w", err)
}
logging.Debug(logging.CatSystem, "generated %d thumbnails", len(result.Thumbnails))
if progressCallback != nil {
progressCallback(1)
}
return nil
}