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
|
// notifyChange triggers the onChange callback if set
|
||||||
|
// Must be called without holding the mutex lock
|
||||||
func (q *Queue) notifyChange() {
|
func (q *Queue) notifyChange() {
|
||||||
if q.onChange != nil {
|
if q.onChange != nil {
|
||||||
q.onChange()
|
// Call in goroutine to avoid blocking and potential deadlocks
|
||||||
|
go q.onChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -326,3 +327,176 @@ func (r *draggableScrollRenderer) Destroy() {}
|
||||||
func (r *draggableScrollRenderer) Objects() []fyne.CanvasObject {
|
func (r *draggableScrollRenderer) Objects() []fyne.CanvasObject {
|
||||||
return []fyne.CanvasObject{r.scroll}
|
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
|
// 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 := canvas.NewText("VIDEOTOOLS", titleColor)
|
||||||
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
title.TextSize = 28
|
title.TextSize = 28
|
||||||
|
|
||||||
queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick)
|
queueTile := buildQueueTile(queueActive, queueTotal, queueColor, textColor, onQueueClick)
|
||||||
|
|
||||||
header := container.New(layout.NewHBoxLayout(),
|
header := container.New(layout.NewHBoxLayout(),
|
||||||
title,
|
title,
|
||||||
|
|
@ -70,12 +70,12 @@ func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fy
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildQueueTile creates the queue status tile
|
// 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 := canvas.NewRectangle(queueColor)
|
||||||
rect.CornerRadius = 8
|
rect.CornerRadius = 8
|
||||||
rect.SetMinSize(fyne.NewSize(160, 60))
|
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.Alignment = fyne.TextAlignCenter
|
||||||
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
text.TextSize = 18
|
text.TextSize = 18
|
||||||
|
|
|
||||||
338
main.go
338
main.go
|
|
@ -57,14 +57,14 @@ var (
|
||||||
queueColor = utils.MustHex("#5961FF")
|
queueColor = utils.MustHex("#5961FF")
|
||||||
|
|
||||||
modulesList = []Module{
|
modulesList = []Module{
|
||||||
{"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet
|
{"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet
|
||||||
{"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue
|
{"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue
|
||||||
{"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan
|
{"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan
|
||||||
{"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green
|
{"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green
|
||||||
{"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green
|
{"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green
|
||||||
{"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow
|
{"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow
|
||||||
{"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange
|
{"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange
|
||||||
{"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red
|
{"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -105,10 +105,10 @@ var formatOptions = []formatOption{
|
||||||
}
|
}
|
||||||
|
|
||||||
type convertConfig struct {
|
type convertConfig struct {
|
||||||
OutputBase string
|
OutputBase string
|
||||||
SelectedFormat formatOption
|
SelectedFormat formatOption
|
||||||
Quality string // Preset quality (Draft/Standard/High/Lossless)
|
Quality string // Preset quality (Draft/Standard/High/Lossless)
|
||||||
Mode string // Simple or Advanced
|
Mode string // Simple or Advanced
|
||||||
|
|
||||||
// Video encoding settings
|
// Video encoding settings
|
||||||
VideoCodec string // H.264, H.265, VP9, AV1, Copy
|
VideoCodec string // H.264, H.265, VP9, AV1, Copy
|
||||||
|
|
@ -123,9 +123,9 @@ type convertConfig struct {
|
||||||
TwoPass bool // Enable two-pass encoding for VBR
|
TwoPass bool // Enable two-pass encoding for VBR
|
||||||
|
|
||||||
// Audio encoding settings
|
// Audio encoding settings
|
||||||
AudioCodec string // AAC, Opus, MP3, FLAC, Copy
|
AudioCodec string // AAC, Opus, MP3, FLAC, Copy
|
||||||
AudioBitrate string // 128k, 192k, 256k, 320k
|
AudioBitrate string // 128k, 192k, 256k, 320k
|
||||||
AudioChannels string // Source, Mono, Stereo, 5.1
|
AudioChannels string // Source, Mono, Stereo, 5.1
|
||||||
|
|
||||||
// Other settings
|
// Other settings
|
||||||
InverseTelecine bool
|
InverseTelecine bool
|
||||||
|
|
@ -154,6 +154,8 @@ type appState struct {
|
||||||
window fyne.Window
|
window fyne.Window
|
||||||
active string
|
active string
|
||||||
source *videoSource
|
source *videoSource
|
||||||
|
loadedVideos []*videoSource // Multiple loaded videos for navigation
|
||||||
|
currentIndex int // Current video index in loadedVideos
|
||||||
anim *previewAnimator
|
anim *previewAnimator
|
||||||
convert convertConfig
|
convert convertConfig
|
||||||
currentFrame string
|
currentFrame string
|
||||||
|
|
@ -172,6 +174,7 @@ type appState struct {
|
||||||
convertStatus string
|
convertStatus string
|
||||||
playSess *playSession
|
playSess *playSession
|
||||||
jobQueue *queue.Queue
|
jobQueue *queue.Queue
|
||||||
|
statsBar *ui.ConversionStatsBar
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) stopPreview() {
|
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 {
|
type playerSurface struct {
|
||||||
obj fyne.CanvasObject
|
obj fyne.CanvasObject
|
||||||
width, height int
|
width, height int
|
||||||
|
|
@ -288,6 +315,34 @@ func (s *appState) setContent(body fyne.CanvasObject) {
|
||||||
s.window.SetContent(container.NewMax(bg, body))
|
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() {
|
func (s *appState) showMainMenu() {
|
||||||
s.stopPreview()
|
s.stopPreview()
|
||||||
s.stopPlayer()
|
s.stopPlayer()
|
||||||
|
|
@ -306,16 +361,29 @@ func (s *appState) showMainMenu() {
|
||||||
|
|
||||||
titleColor := utils.MustHex("#4CE870")
|
titleColor := utils.MustHex("#4CE870")
|
||||||
|
|
||||||
// Get queue stats
|
// Get queue stats - show active jobs (pending+running) out of total
|
||||||
var queueCompleted, queueTotal int
|
var queueActive, queueTotal int
|
||||||
if s.jobQueue != nil {
|
if s.jobQueue != nil {
|
||||||
_, _, completed, _ := s.jobQueue.Stats()
|
pending, running, _, _ := s.jobQueue.Stats()
|
||||||
queueCompleted = completed
|
queueActive = pending + running
|
||||||
queueTotal = len(s.jobQueue.List())
|
queueTotal = len(s.jobQueue.List())
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueCompleted, queueTotal)
|
menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueActive, queueTotal)
|
||||||
s.setContent(container.NewPadded(menu))
|
|
||||||
|
// 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() {
|
func (s *appState) showQueue() {
|
||||||
|
|
@ -327,38 +395,38 @@ func (s *appState) showQueue() {
|
||||||
|
|
||||||
view := ui.BuildQueueView(
|
view := ui.BuildQueueView(
|
||||||
jobs,
|
jobs,
|
||||||
s.showMainMenu, // onBack
|
s.showMainMenu, // onBack
|
||||||
func(id string) { // onPause
|
func(id string) { // onPause
|
||||||
if err := s.jobQueue.Pause(id); err != nil {
|
if err := s.jobQueue.Pause(id); err != nil {
|
||||||
logging.Debug(logging.CatSystem, "failed to pause job: %v", err)
|
logging.Debug(logging.CatSystem, "failed to pause job: %v", err)
|
||||||
}
|
}
|
||||||
s.showQueue() // Refresh
|
s.showQueue() // Refresh
|
||||||
},
|
},
|
||||||
func(id string) { // onResume
|
func(id string) { // onResume
|
||||||
if err := s.jobQueue.Resume(id); err != nil {
|
if err := s.jobQueue.Resume(id); err != nil {
|
||||||
logging.Debug(logging.CatSystem, "failed to resume job: %v", err)
|
logging.Debug(logging.CatSystem, "failed to resume job: %v", err)
|
||||||
}
|
}
|
||||||
s.showQueue() // Refresh
|
s.showQueue() // Refresh
|
||||||
},
|
},
|
||||||
func(id string) { // onCancel
|
func(id string) { // onCancel
|
||||||
if err := s.jobQueue.Cancel(id); err != nil {
|
if err := s.jobQueue.Cancel(id); err != nil {
|
||||||
logging.Debug(logging.CatSystem, "failed to cancel job: %v", err)
|
logging.Debug(logging.CatSystem, "failed to cancel job: %v", err)
|
||||||
}
|
}
|
||||||
s.showQueue() // Refresh
|
s.showQueue() // Refresh
|
||||||
},
|
},
|
||||||
func(id string) { // onRemove
|
func(id string) { // onRemove
|
||||||
if err := s.jobQueue.Remove(id); err != nil {
|
if err := s.jobQueue.Remove(id); err != nil {
|
||||||
logging.Debug(logging.CatSystem, "failed to remove job: %v", err)
|
logging.Debug(logging.CatSystem, "failed to remove job: %v", err)
|
||||||
}
|
}
|
||||||
s.showQueue() // Refresh
|
s.showQueue() // Refresh
|
||||||
},
|
},
|
||||||
func() { // onClear
|
func() { // onClear
|
||||||
s.jobQueue.Clear()
|
s.jobQueue.Clear()
|
||||||
s.showQueue() // Refresh
|
s.showQueue() // Refresh
|
||||||
},
|
},
|
||||||
utils.MustHex("#4CE870"), // titleColor
|
utils.MustHex("#4CE870"), // titleColor
|
||||||
gridColor, // bgColor
|
gridColor, // bgColor
|
||||||
textColor, // textColor
|
textColor, // textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
s.setContent(container.NewPadded(view))
|
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))
|
logging.Debug(logging.CatModule, "batch adding %d videos to queue", len(paths))
|
||||||
|
|
||||||
addedCount := 0
|
addedCount := 0
|
||||||
|
failedCount := 0
|
||||||
|
var failedFiles []string
|
||||||
|
var firstValidPath string
|
||||||
|
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
// Load video metadata
|
// Load video metadata
|
||||||
src, err := probeVideo(path)
|
src, err := probeVideo(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Debug(logging.CatModule, "failed to parse metadata for %s: %v", path, err)
|
logging.Debug(logging.CatModule, "failed to parse metadata for %s: %v", path, err)
|
||||||
|
failedCount++
|
||||||
|
failedFiles = append(failedFiles, filepath.Base(path))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember the first valid video to load later
|
||||||
|
if firstValidPath == "" {
|
||||||
|
firstValidPath = path
|
||||||
|
}
|
||||||
|
|
||||||
// Create job config
|
// Create job config
|
||||||
outDir := filepath.Dir(path)
|
outDir := filepath.Dir(path)
|
||||||
baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||||
|
|
@ -587,12 +666,21 @@ func (s *appState) batchAddToQueue(paths []string) {
|
||||||
|
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
msg := fmt.Sprintf("Added %d video(s) to the queue!", addedCount)
|
if addedCount > 0 {
|
||||||
dialog.ShowInformation("Batch Add", msg, s.window)
|
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
|
// Load all valid videos so user can navigate between them
|
||||||
if len(paths) > 0 {
|
if firstValidPath != "" {
|
||||||
s.loadVideo(paths[0])
|
s.loadVideos(paths)
|
||||||
s.showModule("convert")
|
s.showModule("convert")
|
||||||
}
|
}
|
||||||
}, false)
|
}, false)
|
||||||
|
|
@ -931,10 +1019,10 @@ func runGUI() {
|
||||||
state := &appState{
|
state := &appState{
|
||||||
window: w,
|
window: w,
|
||||||
convert: convertConfig{
|
convert: convertConfig{
|
||||||
OutputBase: "converted",
|
OutputBase: "converted",
|
||||||
SelectedFormat: formatOptions[0],
|
SelectedFormat: formatOptions[0],
|
||||||
Quality: "Standard (CRF 23)",
|
Quality: "Standard (CRF 23)",
|
||||||
Mode: "Simple",
|
Mode: "Simple",
|
||||||
|
|
||||||
// Video encoding defaults
|
// Video encoding defaults
|
||||||
VideoCodec: "H.264",
|
VideoCodec: "H.264",
|
||||||
|
|
@ -949,9 +1037,9 @@ func runGUI() {
|
||||||
TwoPass: false,
|
TwoPass: false,
|
||||||
|
|
||||||
// Audio encoding defaults
|
// Audio encoding defaults
|
||||||
AudioCodec: "AAC",
|
AudioCodec: "AAC",
|
||||||
AudioBitrate: "192k",
|
AudioBitrate: "192k",
|
||||||
AudioChannels: "Source",
|
AudioChannels: "Source",
|
||||||
|
|
||||||
// Other defaults
|
// Other defaults
|
||||||
InverseTelecine: true,
|
InverseTelecine: true,
|
||||||
|
|
@ -966,9 +1054,18 @@ func runGUI() {
|
||||||
playerPaused: true,
|
playerPaused: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize conversion stats bar
|
||||||
|
state.statsBar = ui.NewConversionStatsBar(func() {
|
||||||
|
// Clicking the stats bar opens the queue view
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
|
||||||
// Initialize job queue
|
// Initialize job queue
|
||||||
state.jobQueue = queue.New(state.jobExecutor)
|
state.jobQueue = queue.New(state.jobExecutor)
|
||||||
state.jobQueue.SetChangeCallback(func() {
|
state.jobQueue.SetChangeCallback(func() {
|
||||||
|
// Update stats bar
|
||||||
|
state.updateStatsBar()
|
||||||
|
|
||||||
// Refresh UI when queue changes
|
// Refresh UI when queue changes
|
||||||
if state.active == "" {
|
if state.active == "" {
|
||||||
state.showMainMenu()
|
state.showMainMenu()
|
||||||
|
|
@ -1117,7 +1214,6 @@ func runLogsCLI() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
convertColor := moduleColor("convert")
|
convertColor := moduleColor("convert")
|
||||||
|
|
||||||
|
|
@ -1126,12 +1222,27 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
})
|
})
|
||||||
back.Importance = widget.LowImportance
|
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
|
// Queue button to view queue
|
||||||
queueBtn := widget.NewButton("View Queue", func() {
|
queueBtn := widget.NewButton("View Queue", func() {
|
||||||
state.showQueue()
|
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 updateCover func(string)
|
||||||
var coverDisplay *widget.Label
|
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)
|
videoPanel := buildVideoPane(state, fyne.NewSize(360, 230), src, updateCover)
|
||||||
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(400, 150))
|
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(360, 150))
|
||||||
updateMetaCover = metaCoverUpdate
|
updateMetaCover = metaCoverUpdate
|
||||||
|
|
||||||
var formatLabels []string
|
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
|
// Use VSplit to make panels expand vertically and fill available space
|
||||||
leftColumn := container.NewVSplit(videoPanel, metaPanel)
|
leftColumn := container.NewVSplit(videoPanel, metaPanel)
|
||||||
leftColumn.Offset = 0.65 // Video pane gets 65% of space, metadata gets 35%
|
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(
|
mainArea := container.NewPadded(container.NewVBox(
|
||||||
grid,
|
mainSplit,
|
||||||
snippetRow,
|
snippetRow,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
@ -1577,6 +1689,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
activity.Stop()
|
activity.Stop()
|
||||||
activity.Hide()
|
activity.Hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update stats bar to show live progress
|
||||||
|
state.updateStatsBar()
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
// If conversion finished, stop the ticker after one final update
|
// 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(
|
return container.NewBorder(
|
||||||
backBar,
|
backBar,
|
||||||
container.NewVBox(widget.NewSeparator(), actionBar),
|
bottomSection,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
scrollableMain,
|
scrollableMain,
|
||||||
|
|
@ -1618,7 +1743,6 @@ func makeLabeledPanel(title, body string, min fyne.Size) *fyne.Container {
|
||||||
return container.NewMax(rect, container.NewPadded(box))
|
return container.NewMax(rect, container.NewPadded(box))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.CanvasObject, func()) {
|
func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.CanvasObject, func()) {
|
||||||
outer := canvas.NewRectangle(utils.MustHex("#191F35"))
|
outer := canvas.NewRectangle(utils.MustHex("#191F35"))
|
||||||
outer.CornerRadius = 8
|
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)
|
defaultAspect = float64(src.Height) / float64(src.Width)
|
||||||
}
|
}
|
||||||
baseWidth := float64(min.Width)
|
baseWidth := float64(min.Width)
|
||||||
if baseWidth < 500 {
|
|
||||||
baseWidth = 500
|
|
||||||
}
|
|
||||||
targetWidth := float32(baseWidth)
|
targetWidth := float32(baseWidth)
|
||||||
_ = defaultAspect
|
_ = defaultAspect
|
||||||
targetHeight := float32(min.Height)
|
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)))
|
return container.NewMax(outer, container.NewCenter(container.NewPadded(stack)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type playSession struct {
|
type playSession struct {
|
||||||
path string
|
path string
|
||||||
fps float64
|
fps float64
|
||||||
|
|
@ -2598,7 +2718,6 @@ func (s *appState) detectModuleTileAtPosition(pos fyne.Position) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) loadVideo(path string) {
|
func (s *appState) loadVideo(path string) {
|
||||||
win := s.window
|
|
||||||
if s.playSess != nil {
|
if s.playSess != nil {
|
||||||
s.playSess.Stop()
|
s.playSess.Stop()
|
||||||
s.playSess = nil
|
s.playSess = nil
|
||||||
|
|
@ -2608,7 +2727,7 @@ func (s *appState) loadVideo(path string) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err)
|
logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err)
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
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)
|
}, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2635,6 +2754,11 @@ func (s *appState) loadVideo(path string) {
|
||||||
s.playerReady = false
|
s.playerReady = false
|
||||||
s.playerPos = 0
|
s.playerPos = 0
|
||||||
s.playerPaused = true
|
s.playerPaused = true
|
||||||
|
|
||||||
|
// Set up single-video navigation
|
||||||
|
s.loadedVideos = []*videoSource{src}
|
||||||
|
s.currentIndex = 0
|
||||||
|
|
||||||
logging.Debug(logging.CatModule, "video loaded %+v", src)
|
logging.Debug(logging.CatModule, "video loaded %+v", src)
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
s.showConvertView(src)
|
s.showConvertView(src)
|
||||||
|
|
@ -2645,6 +2769,8 @@ func (s *appState) clearVideo() {
|
||||||
logging.Debug(logging.CatModule, "clearing loaded video")
|
logging.Debug(logging.CatModule, "clearing loaded video")
|
||||||
s.stopPlayer()
|
s.stopPlayer()
|
||||||
s.source = nil
|
s.source = nil
|
||||||
|
s.loadedVideos = nil
|
||||||
|
s.currentIndex = 0
|
||||||
s.currentFrame = ""
|
s.currentFrame = ""
|
||||||
s.convertBusy = false
|
s.convertBusy = false
|
||||||
s.convertStatus = ""
|
s.convertStatus = ""
|
||||||
|
|
@ -2657,6 +2783,94 @@ func (s *appState) clearVideo() {
|
||||||
}, false)
|
}, 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 {
|
func crfForQuality(q string) string {
|
||||||
switch q {
|
switch q {
|
||||||
case "Draft (CRF 28)":
|
case "Draft (CRF 28)":
|
||||||
|
|
@ -2927,7 +3141,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Debug(logging.CatFFMPEG, "convert stdout pipe failed: %v", err)
|
logging.Debug(logging.CatFFMPEG, "convert stdout pipe failed: %v", err)
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
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
|
s.convertBusy = false
|
||||||
setStatus("Failed")
|
setStatus("Failed")
|
||||||
}, false)
|
}, false)
|
||||||
|
|
@ -2991,7 +3205,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
|
||||||
close(progressQuit)
|
close(progressQuit)
|
||||||
logging.Debug(logging.CatFFMPEG, "convert failed to start: %v", err)
|
logging.Debug(logging.CatFFMPEG, "convert failed to start: %v", err)
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
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
|
s.convertBusy = false
|
||||||
setStatus("Failed")
|
setStatus("Failed")
|
||||||
}, false)
|
}, 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()))
|
logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, strings.TrimSpace(stderr.String()))
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
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
|
s.convertBusy = false
|
||||||
setStatus("Failed")
|
setStatus("Failed")
|
||||||
}, false)
|
}, false)
|
||||||
|
|
@ -3026,7 +3240,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
|
||||||
if _, probeErr := probeVideo(outPath); probeErr != nil {
|
if _, probeErr := probeVideo(outPath); probeErr != nil {
|
||||||
logging.Debug(logging.CatFFMPEG, "convert probe failed: %v", probeErr)
|
logging.Debug(logging.CatFFMPEG, "convert probe failed: %v", probeErr)
|
||||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
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
|
s.convertBusy = false
|
||||||
setStatus("Failed")
|
setStatus("Failed")
|
||||||
}, false)
|
}, false)
|
||||||
|
|
@ -3324,7 +3538,6 @@ func capturePreviewFrames(path string, duration float64) ([]string, error) {
|
||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type videoSource struct {
|
type videoSource struct {
|
||||||
Path string
|
Path string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
|
@ -3526,6 +3739,3 @@ func probeVideo(path string) (*videoSource, error) {
|
||||||
|
|
||||||
return src, nil
|
return src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user