Add persistent conversion stats, multi-video navigation, and error debugging

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 <noreply@anthropic.com>
This commit is contained in:
Stu Leak 2025-11-26 18:44:54 -05:00
parent b09ab8d8b4
commit 43ed677838
5 changed files with 590 additions and 69 deletions

135
install.sh Executable file
View File

@ -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 ""

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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

338
main.go
View File

@ -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
}