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:
parent
b09ab8d8b4
commit
43ed677838
135
install.sh
Executable file
135
install.sh
Executable 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 ""
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
338
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user