From 43ed677838f14c16e7d0aebed9fa3f6d91799632 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Wed, 26 Nov 2025 18:44:54 -0500 Subject: [PATCH] Add persistent conversion stats, multi-video navigation, and error debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Add persistent conversion stats bar visible on all screens - Shows running job progress with live updates - Displays pending/completed/failed job counts - Clickable to open queue view - Add multi-video navigation with Prev/Next buttons - Load multiple videos for batch queue setup - Switch between loaded videos to review settings - Add install script with animated loading spinner - Add error dialogs with "Copy Error" button for debugging Improvements: - Update queue tile to show active/total jobs instead of completed/total - Fix deadlock in queue callback system (run callbacks in goroutines) - Improve batch file handling with detailed error reporting - Fix queue status to always show progress percentage (even at 0%) - Better error messages for failed video analysis 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- install.sh | 135 +++++++++++++++ internal/queue/queue.go | 4 +- internal/ui/components.go | 174 ++++++++++++++++++++ internal/ui/mainmenu.go | 8 +- main.go | 338 ++++++++++++++++++++++++++++++-------- 5 files changed, 590 insertions(+), 69 deletions(-) create mode 100755 install.sh diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f7711c2 --- /dev/null +++ b/install.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Spinner function +spinner() { + local pid=$1 + local task=$2 + local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' + local i=0 + + while kill -0 $pid 2>/dev/null; do + i=$(( (i+1) %10 )) + printf "\r${BLUE}${spin:$i:1}${NC} %s..." "$task" + sleep 0.1 + done + printf "\r" +} + +# Configuration +BINARY_NAME="VideoTools" +DEFAULT_INSTALL_PATH="/usr/local/bin" +USER_INSTALL_PATH="$HOME/.local/bin" + +echo "=========================================" +echo " VideoTools Installation Script" +echo "=========================================" +echo "" + +# Check if Go is installed +if ! command -v go &> /dev/null; then + echo -e "${RED}Error: Go is not installed or not in PATH${NC}" + echo "Please install Go 1.21+ from https://go.dev/dl/" + exit 1 +fi + +# Check Go version +GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') +echo -e "${GREEN}✓${NC} Found Go version: $GO_VERSION" + +# Build the binary +echo "" +go build -o "$BINARY_NAME" . > /tmp/videotools-build.log 2>&1 & +BUILD_PID=$! +spinner $BUILD_PID "Building $BINARY_NAME" + +if wait $BUILD_PID; then + echo -e "${GREEN}✓${NC} Build successful" +else + echo -e "${RED}Error: Build failed${NC}" + cat /tmp/videotools-build.log + rm -f /tmp/videotools-build.log + exit 1 +fi +rm -f /tmp/videotools-build.log + +# Determine installation path +echo "" +echo "Where would you like to install $BINARY_NAME?" +echo "1) System-wide (/usr/local/bin) - requires sudo" +echo "2) User-local (~/.local/bin) - no sudo needed" +read -p "Enter choice [1 or 2]: " choice + +case $choice in + 1) + INSTALL_PATH="$DEFAULT_INSTALL_PATH" + NEEDS_SUDO=true + ;; + 2) + INSTALL_PATH="$USER_INSTALL_PATH" + NEEDS_SUDO=false + # Create ~/.local/bin if it doesn't exist + mkdir -p "$INSTALL_PATH" + ;; + *) + echo -e "${RED}Invalid choice. Exiting.${NC}" + rm -f "$BINARY_NAME" + exit 1 + ;; +esac + +# Install the binary +echo "" +if [ "$NEEDS_SUDO" = true ]; then + sudo install -m 755 "$BINARY_NAME" "$INSTALL_PATH/$BINARY_NAME" > /dev/null 2>&1 & + INSTALL_PID=$! + spinner $INSTALL_PID "Installing $BINARY_NAME to $INSTALL_PATH" + + if wait $INSTALL_PID; then + echo -e "${GREEN}✓${NC} Installation successful" + else + echo -e "${RED}Error: Installation failed${NC}" + rm -f "$BINARY_NAME" + exit 1 + fi +else + install -m 755 "$BINARY_NAME" "$INSTALL_PATH/$BINARY_NAME" > /dev/null 2>&1 & + INSTALL_PID=$! + spinner $INSTALL_PID "Installing $BINARY_NAME to $INSTALL_PATH" + + if wait $INSTALL_PID; then + echo -e "${GREEN}✓${NC} Installation successful" + else + echo -e "${RED}Error: Installation failed${NC}" + rm -f "$BINARY_NAME" + exit 1 + fi +fi + +# Clean up the local binary +rm -f "$BINARY_NAME" + +# Check if install path is in PATH +echo "" +if [[ ":$PATH:" != *":$INSTALL_PATH:"* ]]; then + echo -e "${YELLOW}Warning: $INSTALL_PATH is not in your PATH${NC}" + echo "Add the following line to your ~/.bashrc or ~/.zshrc:" + echo "" + echo " export PATH=\"$INSTALL_PATH:\$PATH\"" + echo "" +fi + +echo "=========================================" +echo -e "${GREEN}Installation complete!${NC}" +echo "=========================================" +echo "" +echo "You can now run: $BINARY_NAME" +echo "" diff --git a/internal/queue/queue.go b/internal/queue/queue.go index b776e0c..dcfcd85 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -83,9 +83,11 @@ func (q *Queue) SetChangeCallback(callback func()) { } // notifyChange triggers the onChange callback if set +// Must be called without holding the mutex lock func (q *Queue) notifyChange() { if q.onChange != nil { - q.onChange() + // Call in goroutine to avoid blocking and potential deadlocks + go q.onChange() } } diff --git a/internal/ui/components.go b/internal/ui/components.go index 2a9ad29..8b741e3 100644 --- a/internal/ui/components.go +++ b/internal/ui/components.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "image/color" "strings" @@ -326,3 +327,176 @@ func (r *draggableScrollRenderer) Destroy() {} func (r *draggableScrollRenderer) Objects() []fyne.CanvasObject { return []fyne.CanvasObject{r.scroll} } + +// ConversionStatsBar shows current conversion status with live updates +type ConversionStatsBar struct { + widget.BaseWidget + running int + pending int + completed int + failed int + progress float64 + jobTitle string + onTapped func() +} + +// NewConversionStatsBar creates a new conversion stats bar +func NewConversionStatsBar(onTapped func()) *ConversionStatsBar { + c := &ConversionStatsBar{ + onTapped: onTapped, + } + c.ExtendBaseWidget(c) + return c +} + +// UpdateStats updates the stats display +func (c *ConversionStatsBar) UpdateStats(running, pending, completed, failed int, progress float64, jobTitle string) { + c.running = running + c.pending = pending + c.completed = completed + c.failed = failed + c.progress = progress + c.jobTitle = jobTitle + c.Refresh() +} + +// CreateRenderer creates the renderer for the stats bar +func (c *ConversionStatsBar) CreateRenderer() fyne.WidgetRenderer { + bg := canvas.NewRectangle(color.NRGBA{R: 30, G: 30, B: 30, A: 255}) + bg.CornerRadius = 4 + bg.StrokeColor = GridColor + bg.StrokeWidth = 1 + + statusText := canvas.NewText("", TextColor) + statusText.TextStyle = fyne.TextStyle{Monospace: true} + statusText.TextSize = 11 + + progressBar := widget.NewProgressBar() + + return &conversionStatsRenderer{ + bar: c, + bg: bg, + statusText: statusText, + progressBar: progressBar, + } +} + +// Tapped handles tap events +func (c *ConversionStatsBar) Tapped(*fyne.PointEvent) { + if c.onTapped != nil { + c.onTapped() + } +} + +type conversionStatsRenderer struct { + bar *ConversionStatsBar + bg *canvas.Rectangle + statusText *canvas.Text + progressBar *widget.ProgressBar +} + +func (r *conversionStatsRenderer) Layout(size fyne.Size) { + r.bg.Resize(size) + + // Layout text and progress bar + textSize := r.statusText.MinSize() + padding := float32(8) + + // If there's a running job, show progress bar + if r.bar.running > 0 && r.bar.progress > 0 { + // Show progress bar on right side + barWidth := float32(120) + barHeight := float32(14) + barX := size.Width - barWidth - padding + barY := (size.Height - barHeight) / 2 + + r.progressBar.Resize(fyne.NewSize(barWidth, barHeight)) + r.progressBar.Move(fyne.NewPos(barX, barY)) + r.progressBar.Show() + + // Position text on left + r.statusText.Move(fyne.NewPos(padding, (size.Height-textSize.Height)/2)) + } else { + // No progress bar, center text + r.progressBar.Hide() + r.statusText.Move(fyne.NewPos(padding, (size.Height-textSize.Height)/2)) + } +} + +func (r *conversionStatsRenderer) MinSize() fyne.Size { + return fyne.NewSize(400, 32) +} + +func (r *conversionStatsRenderer) Refresh() { + // Update status text + if r.bar.running > 0 { + statusStr := "" + if r.bar.jobTitle != "" { + // Truncate job title if too long + title := r.bar.jobTitle + if len(title) > 30 { + title = title[:27] + "..." + } + statusStr = title + } else { + statusStr = "Processing" + } + + // Always show progress percentage when running (even if 0%) + statusStr += " • " + formatProgress(r.bar.progress) + + if r.bar.pending > 0 { + statusStr += " • " + formatCount(r.bar.pending, "pending") + } + + r.statusText.Text = "▶ " + statusStr + r.statusText.Color = color.NRGBA{R: 100, G: 220, B: 100, A: 255} // Green + + // Update progress bar (show even at 0%) + r.progressBar.SetValue(r.bar.progress / 100.0) + r.progressBar.Show() + } else if r.bar.pending > 0 { + r.statusText.Text = "⏸ " + formatCount(r.bar.pending, "queued") + r.statusText.Color = color.NRGBA{R: 255, G: 200, B: 100, A: 255} // Yellow + r.progressBar.Hide() + } else if r.bar.completed > 0 || r.bar.failed > 0 { + statusStr := "✓ " + parts := []string{} + if r.bar.completed > 0 { + parts = append(parts, formatCount(r.bar.completed, "completed")) + } + if r.bar.failed > 0 { + parts = append(parts, formatCount(r.bar.failed, "failed")) + } + statusStr += strings.Join(parts, " • ") + r.statusText.Text = statusStr + r.statusText.Color = color.NRGBA{R: 150, G: 150, B: 150, A: 255} // Gray + r.progressBar.Hide() + } else { + r.statusText.Text = "○ No active jobs" + r.statusText.Color = color.NRGBA{R: 100, G: 100, B: 100, A: 255} // Dim gray + r.progressBar.Hide() + } + + r.statusText.Refresh() + r.progressBar.Refresh() + r.bg.Refresh() +} + +func (r *conversionStatsRenderer) Destroy() {} + +func (r *conversionStatsRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.bg, r.statusText, r.progressBar} +} + +// Helper functions for formatting +func formatProgress(progress float64) string { + return fmt.Sprintf("%.1f%%", progress) +} + +func formatCount(count int, label string) string { + if count == 1 { + return fmt.Sprintf("1 %s", label) + } + return fmt.Sprintf("%d %s", count, label) +} diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index d81c823..7988866 100644 --- a/internal/ui/mainmenu.go +++ b/internal/ui/mainmenu.go @@ -20,12 +20,12 @@ type ModuleInfo struct { } // BuildMainMenu creates the main menu view with module tiles -func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int) fyne.CanvasObject { +func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), titleColor, queueColor, textColor color.Color, queueActive, queueTotal int) fyne.CanvasObject { title := canvas.NewText("VIDEOTOOLS", titleColor) title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} title.TextSize = 28 - queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) + queueTile := buildQueueTile(queueActive, queueTotal, queueColor, textColor, onQueueClick) header := container.New(layout.NewHBoxLayout(), title, @@ -70,12 +70,12 @@ func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fy } // buildQueueTile creates the queue status tile -func buildQueueTile(done, total int, queueColor, textColor color.Color, onClick func()) fyne.CanvasObject { +func buildQueueTile(active, total int, queueColor, textColor color.Color, onClick func()) fyne.CanvasObject { rect := canvas.NewRectangle(queueColor) rect.CornerRadius = 8 rect.SetMinSize(fyne.NewSize(160, 60)) - text := canvas.NewText(fmt.Sprintf("QUEUE: %d/%d", done, total), textColor) + text := canvas.NewText(fmt.Sprintf("QUEUE: %d/%d", active, total), textColor) text.Alignment = fyne.TextAlignCenter text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} text.TextSize = 18 diff --git a/main.go b/main.go index 9e464be..d6f58c6 100644 --- a/main.go +++ b/main.go @@ -57,14 +57,14 @@ var ( queueColor = utils.MustHex("#5961FF") modulesList = []Module{ - {"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet - {"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue - {"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan - {"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green - {"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green - {"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow - {"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange - {"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red + {"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet + {"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue + {"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan + {"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green + {"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green + {"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow + {"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange + {"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red } ) @@ -105,10 +105,10 @@ var formatOptions = []formatOption{ } type convertConfig struct { - OutputBase string - SelectedFormat formatOption - Quality string // Preset quality (Draft/Standard/High/Lossless) - Mode string // Simple or Advanced + OutputBase string + SelectedFormat formatOption + Quality string // Preset quality (Draft/Standard/High/Lossless) + Mode string // Simple or Advanced // Video encoding settings VideoCodec string // H.264, H.265, VP9, AV1, Copy @@ -123,9 +123,9 @@ type convertConfig struct { TwoPass bool // Enable two-pass encoding for VBR // Audio encoding settings - AudioCodec string // AAC, Opus, MP3, FLAC, Copy - AudioBitrate string // 128k, 192k, 256k, 320k - AudioChannels string // Source, Mono, Stereo, 5.1 + AudioCodec string // AAC, Opus, MP3, FLAC, Copy + AudioBitrate string // 128k, 192k, 256k, 320k + AudioChannels string // Source, Mono, Stereo, 5.1 // Other settings InverseTelecine bool @@ -154,6 +154,8 @@ type appState struct { window fyne.Window active string source *videoSource + loadedVideos []*videoSource // Multiple loaded videos for navigation + currentIndex int // Current video index in loadedVideos anim *previewAnimator convert convertConfig currentFrame string @@ -172,6 +174,7 @@ type appState struct { convertStatus string playSess *playSession jobQueue *queue.Queue + statsBar *ui.ConversionStatsBar } func (s *appState) stopPreview() { @@ -181,6 +184,30 @@ func (s *appState) stopPreview() { } } +func (s *appState) updateStatsBar() { + if s.statsBar == nil || s.jobQueue == nil { + return + } + + pending, running, completed, failed := s.jobQueue.Stats() + + // Find the currently running job to get its progress + var progress float64 + var jobTitle string + if running > 0 { + jobs := s.jobQueue.List() + for _, job := range jobs { + if job.Status == queue.JobStatusRunning { + progress = job.Progress + jobTitle = job.Title + break + } + } + } + + s.statsBar.UpdateStats(running, pending, completed, failed, progress, jobTitle) +} + type playerSurface struct { obj fyne.CanvasObject width, height int @@ -288,6 +315,34 @@ func (s *appState) setContent(body fyne.CanvasObject) { s.window.SetContent(container.NewMax(bg, body)) } +// showErrorWithCopy displays an error dialog with a "Copy Error" button +func (s *appState) showErrorWithCopy(title string, err error) { + errMsg := err.Error() + + // Create error message label + errorLabel := widget.NewLabel(errMsg) + errorLabel.Wrapping = fyne.TextWrapWord + + // Create copy button + copyBtn := widget.NewButton("Copy Error", func() { + s.window.Clipboard().SetContent(errMsg) + }) + + // Create dialog content + content := container.NewBorder( + errorLabel, + copyBtn, + nil, + nil, + nil, + ) + + // Show custom dialog + d := dialog.NewCustom(title, "Close", content, s.window) + d.Resize(fyne.NewSize(500, 200)) + d.Show() +} + func (s *appState) showMainMenu() { s.stopPreview() s.stopPlayer() @@ -306,16 +361,29 @@ func (s *appState) showMainMenu() { titleColor := utils.MustHex("#4CE870") - // Get queue stats - var queueCompleted, queueTotal int + // Get queue stats - show active jobs (pending+running) out of total + var queueActive, queueTotal int if s.jobQueue != nil { - _, _, completed, _ := s.jobQueue.Stats() - queueCompleted = completed + pending, running, _, _ := s.jobQueue.Stats() + queueActive = pending + running queueTotal = len(s.jobQueue.List()) } - menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueCompleted, queueTotal) - s.setContent(container.NewPadded(menu)) + menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueActive, queueTotal) + + // Update stats bar + s.updateStatsBar() + + // Add stats bar at the bottom of the menu + content := container.NewBorder( + nil, // top + s.statsBar, // bottom + nil, // left + nil, // right + container.NewPadded(menu), // center + ) + + s.setContent(content) } func (s *appState) showQueue() { @@ -327,38 +395,38 @@ func (s *appState) showQueue() { view := ui.BuildQueueView( jobs, - s.showMainMenu, // onBack - func(id string) { // onPause + s.showMainMenu, // onBack + func(id string) { // onPause if err := s.jobQueue.Pause(id); err != nil { logging.Debug(logging.CatSystem, "failed to pause job: %v", err) } s.showQueue() // Refresh }, - func(id string) { // onResume + func(id string) { // onResume if err := s.jobQueue.Resume(id); err != nil { logging.Debug(logging.CatSystem, "failed to resume job: %v", err) } s.showQueue() // Refresh }, - func(id string) { // onCancel + func(id string) { // onCancel if err := s.jobQueue.Cancel(id); err != nil { logging.Debug(logging.CatSystem, "failed to cancel job: %v", err) } s.showQueue() // Refresh }, - func(id string) { // onRemove + func(id string) { // onRemove if err := s.jobQueue.Remove(id); err != nil { logging.Debug(logging.CatSystem, "failed to remove job: %v", err) } s.showQueue() // Refresh }, - func() { // onClear + func() { // onClear s.jobQueue.Clear() s.showQueue() // Refresh }, - utils.MustHex("#4CE870"), // titleColor - gridColor, // bgColor - textColor, // textColor + utils.MustHex("#4CE870"), // titleColor + gridColor, // bgColor + textColor, // textColor ) s.setContent(container.NewPadded(view)) @@ -529,14 +597,25 @@ func (s *appState) batchAddToQueue(paths []string) { logging.Debug(logging.CatModule, "batch adding %d videos to queue", len(paths)) addedCount := 0 + failedCount := 0 + var failedFiles []string + var firstValidPath string + for _, path := range paths { // Load video metadata src, err := probeVideo(path) if err != nil { logging.Debug(logging.CatModule, "failed to parse metadata for %s: %v", path, err) + failedCount++ + failedFiles = append(failedFiles, filepath.Base(path)) continue } + // Remember the first valid video to load later + if firstValidPath == "" { + firstValidPath = path + } + // Create job config outDir := filepath.Dir(path) baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) @@ -587,12 +666,21 @@ func (s *appState) batchAddToQueue(paths []string) { // Show confirmation dialog fyne.CurrentApp().Driver().DoFromGoroutine(func() { - msg := fmt.Sprintf("Added %d video(s) to the queue!", addedCount) - dialog.ShowInformation("Batch Add", msg, s.window) + if addedCount > 0 { + msg := fmt.Sprintf("Added %d video(s) to the queue!", addedCount) + if failedCount > 0 { + msg += fmt.Sprintf("\n\n%d file(s) failed to analyze:\n%s", failedCount, strings.Join(failedFiles, ", ")) + } + dialog.ShowInformation("Batch Add", msg, s.window) + } else { + // All files failed + msg := fmt.Sprintf("Failed to analyze %d file(s):\n%s", failedCount, strings.Join(failedFiles, ", ")) + s.showErrorWithCopy("Batch Add Failed", fmt.Errorf("%s", msg)) + } - // Load the first video so user can adjust settings if needed - if len(paths) > 0 { - s.loadVideo(paths[0]) + // Load all valid videos so user can navigate between them + if firstValidPath != "" { + s.loadVideos(paths) s.showModule("convert") } }, false) @@ -931,10 +1019,10 @@ func runGUI() { state := &appState{ window: w, convert: convertConfig{ - OutputBase: "converted", - SelectedFormat: formatOptions[0], - Quality: "Standard (CRF 23)", - Mode: "Simple", + OutputBase: "converted", + SelectedFormat: formatOptions[0], + Quality: "Standard (CRF 23)", + Mode: "Simple", // Video encoding defaults VideoCodec: "H.264", @@ -949,9 +1037,9 @@ func runGUI() { TwoPass: false, // Audio encoding defaults - AudioCodec: "AAC", - AudioBitrate: "192k", - AudioChannels: "Source", + AudioCodec: "AAC", + AudioBitrate: "192k", + AudioChannels: "Source", // Other defaults InverseTelecine: true, @@ -966,9 +1054,18 @@ func runGUI() { playerPaused: true, } + // Initialize conversion stats bar + state.statsBar = ui.NewConversionStatsBar(func() { + // Clicking the stats bar opens the queue view + state.showQueue() + }) + // Initialize job queue state.jobQueue = queue.New(state.jobExecutor) state.jobQueue.SetChangeCallback(func() { + // Update stats bar + state.updateStatsBar() + // Refresh UI when queue changes if state.active == "" { state.showMainMenu() @@ -1117,7 +1214,6 @@ func runLogsCLI() error { return nil } - func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { convertColor := moduleColor("convert") @@ -1126,12 +1222,27 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { }) back.Importance = widget.LowImportance + // Navigation buttons for multiple loaded videos + var navButtons fyne.CanvasObject + if len(state.loadedVideos) > 1 { + prevBtn := widget.NewButton("◀ Prev", func() { + state.prevVideo() + }) + nextBtn := widget.NewButton("Next ▶", func() { + state.nextVideo() + }) + videoCounter := widget.NewLabel(fmt.Sprintf("Video %d of %d", state.currentIndex+1, len(state.loadedVideos))) + navButtons = container.NewHBox(prevBtn, videoCounter, nextBtn) + } else { + navButtons = container.NewHBox() + } + // Queue button to view queue queueBtn := widget.NewButton("View Queue", func() { state.showQueue() }) - backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer(), queueBtn)) + backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer(), navButtons, layout.NewSpacer(), queueBtn)) var updateCover func(string) var coverDisplay *widget.Label @@ -1151,8 +1262,8 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { } } - videoPanel := buildVideoPane(state, fyne.NewSize(400, 250), src, updateCover) - metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(400, 150)) + videoPanel := buildVideoPane(state, fyne.NewSize(360, 230), src, updateCover) + metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(360, 150)) updateMetaCover = metaCoverUpdate var formatLabels []string @@ -1465,9 +1576,10 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { // Use VSplit to make panels expand vertically and fill available space leftColumn := container.NewVSplit(videoPanel, metaPanel) leftColumn.Offset = 0.65 // Video pane gets 65% of space, metadata gets 35% - grid := container.NewGridWithColumns(2, leftColumn, optionsPanel) + mainSplit := container.NewHSplit(leftColumn, optionsPanel) + mainSplit.Offset = 0.45 // Give the options panel extra horizontal room mainArea := container.NewPadded(container.NewVBox( - grid, + mainSplit, snippetRow, )) @@ -1577,6 +1689,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { activity.Stop() activity.Hide() } + + // Update stats bar to show live progress + state.updateStatsBar() }, false) // If conversion finished, stop the ticker after one final update @@ -1594,9 +1709,19 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { } }() + // Update stats bar + state.updateStatsBar() + + // Add stats bar above the action bar at the bottom + bottomSection := container.NewVBox( + state.statsBar, + widget.NewSeparator(), + actionBar, + ) + return container.NewBorder( backBar, - container.NewVBox(widget.NewSeparator(), actionBar), + bottomSection, nil, nil, scrollableMain, @@ -1618,7 +1743,6 @@ func makeLabeledPanel(title, body string, min fyne.Size) *fyne.Container { return container.NewMax(rect, container.NewPadded(box)) } - func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.CanvasObject, func()) { outer := canvas.NewRectangle(utils.MustHex("#191F35")) outer.CornerRadius = 8 @@ -1762,9 +1886,6 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu defaultAspect = float64(src.Height) / float64(src.Width) } baseWidth := float64(min.Width) - if baseWidth < 500 { - baseWidth = 500 - } targetWidth := float32(baseWidth) _ = defaultAspect targetHeight := float32(min.Height) @@ -2035,7 +2156,6 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu return container.NewMax(outer, container.NewCenter(container.NewPadded(stack))) } - type playSession struct { path string fps float64 @@ -2598,7 +2718,6 @@ func (s *appState) detectModuleTileAtPosition(pos fyne.Position) string { } func (s *appState) loadVideo(path string) { - win := s.window if s.playSess != nil { s.playSess.Stop() s.playSess = nil @@ -2608,7 +2727,7 @@ func (s *appState) loadVideo(path string) { if err != nil { logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - dialog.ShowError(fmt.Errorf("failed to analyze %s: %w", filepath.Base(path), err), win) + s.showErrorWithCopy("Failed to Analyze Video", fmt.Errorf("failed to analyze %s: %w", filepath.Base(path), err)) }, false) return } @@ -2635,6 +2754,11 @@ func (s *appState) loadVideo(path string) { s.playerReady = false s.playerPos = 0 s.playerPaused = true + + // Set up single-video navigation + s.loadedVideos = []*videoSource{src} + s.currentIndex = 0 + logging.Debug(logging.CatModule, "video loaded %+v", src) fyne.CurrentApp().Driver().DoFromGoroutine(func() { s.showConvertView(src) @@ -2645,6 +2769,8 @@ func (s *appState) clearVideo() { logging.Debug(logging.CatModule, "clearing loaded video") s.stopPlayer() s.source = nil + s.loadedVideos = nil + s.currentIndex = 0 s.currentFrame = "" s.convertBusy = false s.convertStatus = "" @@ -2657,6 +2783,94 @@ func (s *appState) clearVideo() { }, false) } +// loadVideos loads multiple videos for navigation +func (s *appState) loadVideos(paths []string) { + s.loadedVideos = nil + s.currentIndex = 0 + + // Load all videos + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err) + continue + } + + if frames, err := capturePreviewFrames(src.Path, src.Duration); err == nil { + src.PreviewFrames = frames + } + + s.loadedVideos = append(s.loadedVideos, src) + } + + if len(s.loadedVideos) == 0 { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showErrorWithCopy("Failed to Load Videos", fmt.Errorf("no valid videos to load")) + }, false) + return + } + + // Load the first video + s.switchToVideo(0) +} + +// switchToVideo switches to a specific video by index +func (s *appState) switchToVideo(index int) { + if index < 0 || index >= len(s.loadedVideos) { + return + } + + s.currentIndex = index + src := s.loadedVideos[index] + s.source = src + + if len(src.PreviewFrames) > 0 { + s.currentFrame = src.PreviewFrames[0] + } else { + s.currentFrame = "" + } + + s.applyInverseDefaults(src) + base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) + s.convert.OutputBase = base + "-convert" + + if src.EmbeddedCoverArt != "" { + s.convert.CoverArtPath = src.EmbeddedCoverArt + } else { + s.convert.CoverArtPath = "" + } + + s.convert.AspectHandling = "Auto" + s.playerReady = false + s.playerPos = 0 + s.playerPaused = true + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showConvertView(src) + }, false) +} + +// nextVideo switches to the next loaded video +func (s *appState) nextVideo() { + if len(s.loadedVideos) == 0 { + return + } + nextIndex := (s.currentIndex + 1) % len(s.loadedVideos) + s.switchToVideo(nextIndex) +} + +// prevVideo switches to the previous loaded video +func (s *appState) prevVideo() { + if len(s.loadedVideos) == 0 { + return + } + prevIndex := s.currentIndex - 1 + if prevIndex < 0 { + prevIndex = len(s.loadedVideos) - 1 + } + s.switchToVideo(prevIndex) +} + func crfForQuality(q string) string { switch q { case "Draft (CRF 28)": @@ -2927,7 +3141,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But if err != nil { logging.Debug(logging.CatFFMPEG, "convert stdout pipe failed: %v", err) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window) + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("convert failed: %w", err)) s.convertBusy = false setStatus("Failed") }, false) @@ -2991,7 +3205,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But close(progressQuit) logging.Debug(logging.CatFFMPEG, "convert failed to start: %v", err) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window) + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("convert failed: %w", err)) s.convertBusy = false setStatus("Failed") }, false) @@ -3013,7 +3227,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But } logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, strings.TrimSpace(stderr.String())) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window) + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("convert failed: %w", err)) s.convertBusy = false setStatus("Failed") }, false) @@ -3026,7 +3240,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But if _, probeErr := probeVideo(outPath); probeErr != nil { logging.Debug(logging.CatFFMPEG, "convert probe failed: %v", probeErr) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - dialog.ShowError(fmt.Errorf("conversion output is invalid: %w", probeErr), s.window) + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("conversion output is invalid: %w", probeErr)) s.convertBusy = false setStatus("Failed") }, false) @@ -3324,7 +3538,6 @@ func capturePreviewFrames(path string, duration float64) ([]string, error) { return files, nil } - type videoSource struct { Path string DisplayName string @@ -3526,6 +3739,3 @@ func probeVideo(path string) (*videoSource, error) { return src, nil } - - -