package main
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VT_Player/internal/convert"
"git.leaktechnologies.dev/stu/VT_Player/internal/logging"
"git.leaktechnologies.dev/stu/VT_Player/internal/modules"
"git.leaktechnologies.dev/stu/VT_Player/internal/player"
"git.leaktechnologies.dev/stu/VT_Player/internal/queue"
"git.leaktechnologies.dev/stu/VT_Player/internal/ui"
"git.leaktechnologies.dev/stu/VT_Player/internal/utils"
"github.com/hajimehoshi/oto"
)
// Module describes a high level tool surface that gets a tile on the menu.
type Module struct {
ID string
Label string
Color color.Color
Handle func(files []string)
}
var (
debugFlag = flag.Bool("debug", false, "enable verbose logging (env: VIDEOTOOLS_DEBUG=1)")
backgroundColor = utils.MustHex("#0B0F1A")
gridColor = utils.MustHex("#171C2A")
textColor = utils.MustHex("#E1EEFF")
queueColor = utils.MustHex("#5961FF")
modulesList = []Module{
{"player", "Player", utils.MustHex("#4CE870"), modules.HandlePlayer},
}
)
// moduleColor returns the color for a given module ID
func moduleColor(id string) color.Color {
for _, m := range modulesList {
if m.ID == id {
return m.Color
}
}
return queueColor
}
// resolveTargetAspect resolves an aspect ratio value or source aspect
func resolveTargetAspect(val string, src *videoSource) float64 {
if strings.EqualFold(val, "source") {
if src != nil {
return utils.AspectRatioFloat(src.Width, src.Height)
}
return 0
}
if r := utils.ParseAspectValue(val); r > 0 {
return r
}
return 0
}
type formatOption struct {
Label string
Ext string
VideoCodec string
}
var formatOptions = []formatOption{
{"MP4 (H.264)", ".mp4", "libx264"},
{"MKV (H.265)", ".mkv", "libx265"},
{"MOV (ProRes)", ".mov", "prores_ks"},
{"DVD-NTSC (MPEG-2)", ".mpg", "mpeg2video"},
{"DVD-PAL (MPEG-2)", ".mpg", "mpeg2video"},
}
type convertConfig struct {
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
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
CRF string // Manual CRF value (0-51, or empty to use Quality preset)
BitrateMode string // CRF, CBR, VBR, "Target Size"
VideoBitrate string // For CBR/VBR modes (e.g., "5000k")
TargetFileSize string // Target file size (e.g., "25MB", "100MB") - requires BitrateMode="Target Size"
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
FrameRate string // Source, 24, 30, 60, or custom
PixelFormat string // yuv420p, yuv422p, yuv444p
HardwareAccel string // none, nvenc, vaapi, qsv, videotoolbox
TwoPass bool // Enable two-pass encoding for VBR
H264Profile string // baseline, main, high (for H.264 compatibility)
H264Level string // 3.0, 3.1, 4.0, 4.1, 5.0, 5.1 (for H.264 compatibility)
Deinterlace string // Auto, Force, Off
DeinterlaceMethod string // yadif, bwdif (bwdif is higher quality but slower)
AutoCrop bool // Auto-detect and remove black bars
CropWidth string // Manual crop width (empty = use auto-detect)
CropHeight string // Manual crop height (empty = use auto-detect)
CropX string // Manual crop X offset (empty = use auto-detect)
CropY string // Manual crop Y offset (empty = use auto-detect)
// Audio encoding settings
AudioCodec string // AAC, Opus, MP3, FLAC, Copy
AudioBitrate string // 128k, 192k, 256k, 320k
AudioChannels string // Source, Mono, Stereo, 5.1
AudioSampleRate string // Source, 44100, 48000
NormalizeAudio bool // Force stereo + 48kHz for compatibility
// Other settings
InverseTelecine bool
InverseAutoNotes string
CoverArtPath string
AspectHandling string
OutputAspect string
}
func (c convertConfig) OutputFile() string {
base := strings.TrimSpace(c.OutputBase)
if base == "" {
base = "converted"
}
return base + c.SelectedFormat.Ext
}
func (c convertConfig) CoverLabel() string {
if strings.TrimSpace(c.CoverArtPath) == "" {
return "none"
}
return filepath.Base(c.CoverArtPath)
}
type appState struct {
window fyne.Window
active string
lastModule string
source *videoSource
loadedVideos []*videoSource // Multiple loaded videos for navigation
currentIndex int // Current video index in loadedVideos
anim *previewAnimator
convert convertConfig
currentFrame string
player player.Controller
playerReady bool
playerVolume float64
playerMuted bool
lastVolume float64
playerPaused bool
playerPos float64
playerLast time.Time
compareSess1 *playSession
compareSess2 *playSession
progressQuit chan struct{}
convertCancel context.CancelFunc
playerSurf *playerSurface
convertBusy bool
convertStatus string
convertActiveIn string
convertActiveOut string
convertProgress float64
playSess *playSession
jobQueue *queue.Queue
statsBar *ui.ConversionStatsBar
queueBtn *widget.Button
queueScroll *container.Scroll
queueOffset fyne.Position
compareFile1 *videoSource
compareFile2 *videoSource
keyframingMode bool // Toggle for frame-accurate editing features
}
func (s *appState) stopPreview() {
if s.anim != nil {
s.anim.Stop()
s.anim = nil
}
}
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
}
}
} else if s.convertBusy {
// Reflect direct conversion as an active job in the stats bar
running = 1
in := filepath.Base(s.convertActiveIn)
if in == "" && s.source != nil {
in = filepath.Base(s.source.Path)
}
jobTitle = fmt.Sprintf("Direct convert: %s", in)
progress = s.convertProgress
}
s.statsBar.UpdateStats(running, pending, completed, failed, progress, jobTitle)
}
func (s *appState) queueProgressCounts() (completed, total int) {
if s.jobQueue == nil {
return 0, 0
}
pending, running, completedCount, failed := s.jobQueue.Stats()
// Total includes all jobs in memory, including cancelled/failed/pending
total = len(s.jobQueue.List())
// Include direct conversion as an in-flight item in totals
if s.convertBusy {
total++
}
completed = completedCount
_ = pending
_ = running
_ = failed
return
}
func (s *appState) updateQueueButtonLabel() {
if s.queueBtn == nil {
return
}
completed, total := s.queueProgressCounts()
// Include active direct conversion in totals
if s.convertBusy {
total++
}
label := "View Queue"
if total > 0 {
label = fmt.Sprintf("View Queue %d/%d", completed, total)
}
s.queueBtn.SetText(label)
}
type playerSurface struct {
obj fyne.CanvasObject
width, height int
}
func (s *appState) setPlayerSurface(obj fyne.CanvasObject, w, h int) {
s.playerSurf = &playerSurface{obj: obj, width: w, height: h}
s.syncPlayerWindow()
}
func (s *appState) currentPlayerPos() float64 {
if s.playerPaused {
return s.playerPos
}
return s.playerPos + time.Since(s.playerLast).Seconds()
}
func (s *appState) stopProgressLoop() {
if s.progressQuit != nil {
close(s.progressQuit)
s.progressQuit = nil
}
}
func (s *appState) startProgressLoop(maxDur float64, slider *widget.Slider, update func(float64)) {
s.stopProgressLoop()
stop := make(chan struct{})
s.progressQuit = stop
ticker := time.NewTicker(200 * time.Millisecond)
go func() {
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
pos := s.currentPlayerPos()
if pos < 0 {
pos = 0
}
if pos > maxDur {
pos = maxDur
}
if update != nil {
update(pos)
}
if slider != nil {
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
slider.SetValue(pos)
}, false)
}
}
}
}()
}
func (s *appState) syncPlayerWindow() {
if s.player == nil || s.playerSurf == nil || s.playerSurf.obj == nil {
return
}
driver := fyne.CurrentApp().Driver()
pos := driver.AbsolutePositionForObject(s.playerSurf.obj)
width := s.playerSurf.width
height := s.playerSurf.height
if width <= 0 || height <= 0 {
return
}
s.player.SetWindow(int(pos.X), int(pos.Y), width, height)
logging.Debug(logging.CatUI, "player window target pos=(%d,%d) size=%dx%d", int(pos.X), int(pos.Y), width, height)
}
func (s *appState) startPreview(frames []string, img *canvas.Image, slider *widget.Slider) {
if len(frames) == 0 {
return
}
anim := &previewAnimator{frames: frames, img: img, slider: slider, stop: make(chan struct{}), playing: true, state: s}
s.anim = anim
anim.Start()
}
func (s *appState) hasSource() bool {
return s.source != nil
}
func (s *appState) applyInverseDefaults(src *videoSource) {
if src == nil {
return
}
if src.IsProgressive() {
s.convert.InverseTelecine = false
s.convert.InverseAutoNotes = "Progressive source detected; inverse telecine disabled."
} else {
s.convert.InverseTelecine = true
s.convert.InverseAutoNotes = "Interlaced source detected; smoothing enabled."
}
}
func (s *appState) setContent(body fyne.CanvasObject) {
update := func() {
bg := canvas.NewRectangle(backgroundColor)
// Don't set a minimum size - let content determine layout naturally
if body == nil {
s.window.SetContent(bg)
return
}
s.window.SetContent(container.NewMax(bg, body))
}
// Use async Do() instead of DoAndWait() to avoid deadlock when called from main goroutine
fyne.Do(update)
}
// 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() {
// Minimal entry point: go straight to the player view.
s.showPlayerView()
}
// showCompareView renders a simple side-by-side player for the first two loaded videos.
func (s *appState) showCompareView() {
s.stopPreview()
s.stopPlayer()
s.stopCompareSessions()
s.active = "compare"
header := widget.NewLabelWithStyle("Compare", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
backBtn := widget.NewButton("Back to Player", func() { s.showPlayerView() })
headerRow := container.NewHBox(header, layout.NewSpacer(), backBtn)
if len(s.loadedVideos) < 2 {
icon := canvas.NewText("⬆", utils.MustHex("#4CE870"))
icon.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
icon.TextSize = 28
msg := widget.NewLabel("Load at least two videos to compare.")
msg.Alignment = fyne.TextAlignCenter
s.setContent(container.NewBorder(container.NewPadded(headerRow), nil, nil, nil, container.NewCenter(container.NewVBox(container.NewCenter(icon), container.NewCenter(msg)))))
return
}
src1 := s.loadedVideos[0]
src2 := s.loadedVideos[1]
// Left player
left := s.buildComparePane(src1, func() { s.stopCompareSessions() }, func(ps *playSession) { s.compareSess1 = ps })
// Right player
right := s.buildComparePane(src2, func() { s.stopCompareSessions() }, func(ps *playSession) { s.compareSess2 = ps })
body := container.NewGridWithColumns(2, left, right)
s.setContent(container.NewBorder(container.NewPadded(headerRow), nil, nil, nil, container.NewPadded(body)))
}
// buildComparePane builds a simple player pane for compare view.
func (s *appState) buildComparePane(src *videoSource, onStop func(), setSess func(*playSession)) fyne.CanvasObject {
stageBG := canvas.NewRectangle(utils.MustHex("#0F1529"))
stageBG.SetMinSize(fyne.NewSize(640, 360))
videoImg := canvas.NewImageFromResource(nil)
videoImg.FillMode = canvas.ImageFillContain
stage := container.NewMax(stageBG, videoImg)
currentTime := widget.NewLabel("0:00")
totalTime := widget.NewLabel(src.DurationString())
totalTime.Alignment = fyne.TextAlignTrailing
slider := widget.NewSlider(0, math.Max(1, src.Duration))
slider.Step = 0.5
var updatingProgress bool
var sess *playSession
updateProgress := func(val float64) {
fyne.Do(func() {
updatingProgress = true
currentTime.SetText(formatClock(val))
slider.SetValue(val)
updatingProgress = false
})
}
ensureSession := func() *playSession {
if sess == nil {
ps := newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, 640, 360, updateProgress, videoImg)
ps.SetVolume(100)
setSess(ps)
sess = ps
}
return sess
}
slider.OnChanged = func(val float64) {
if updatingProgress {
return
}
updateProgress(val)
if ps := ensureSession(); ps != nil {
ps.Seek(val)
}
}
playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() {
if ps := ensureSession(); ps != nil {
if ps.paused {
ps.Play()
ps.paused = false
} else {
ps.Pause()
ps.paused = true
}
}
})
var volIcon *widget.Button
volIcon = utils.MakeIconButton("🔊", "Mute/Unmute", func() {
if ps := ensureSession(); ps != nil {
if ps.muted || ps.volume <= 0 {
ps.SetVolume(50)
} else {
ps.SetVolume(0)
}
if ps.muted || ps.volume <= 0 {
volIcon.SetText("🔇")
} else {
volIcon.SetText("🔊")
}
}
})
volSlider := widget.NewSlider(0, 100)
volSlider.Step = 1
volSlider.Value = 100
volSlider.OnChanged = func(val float64) {
if ps := ensureSession(); ps != nil {
ps.SetVolume(val)
if ps.muted || ps.volume <= 0 {
volIcon.SetText("🔇")
} else {
volIcon.SetText("🔊")
}
}
}
progressBar := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
controlRow := container.NewHBox(playBtn, layout.NewSpacer(), volIcon, container.NewMax(volSlider))
title := widget.NewLabelWithStyle(filepath.Base(src.Path), fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
return container.NewVBox(
title,
container.NewPadded(stage),
container.NewPadded(progressBar),
container.NewPadded(controlRow),
)
}
// showPlayerView renders the player-focused UI with a lightweight playlist.
func (s *appState) showPlayerView() {
s.stopPreview()
s.stopPlayer()
s.stopCompareSessions()
s.active = "player"
// Helper to refresh the view after selection/loads.
refresh := func() {
s.showPlayerView()
}
// Create menu bar
fileMenu := fyne.NewMenu("File",
fyne.NewMenuItem("Open File…", func() {
dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil || r == nil {
return
}
path := r.URI().Path()
r.Close()
go s.loadVideo(path)
}, s.window)
dlg.Resize(fyne.NewSize(700, 480))
dlg.Show()
}),
fyne.NewMenuItem("Open Folder…", func() {
dlg := dialog.NewFolderOpen(func(l fyne.ListableURI, err error) {
if err != nil || l == nil {
return
}
paths := s.findVideoFiles(l.Path())
if len(paths) == 0 {
return
}
go s.loadVideos(paths)
}, s.window)
dlg.Resize(fyne.NewSize(700, 480))
dlg.Show()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Clear Playlist", func() {
s.clearVideo()
refresh()
}),
)
viewMenu := fyne.NewMenu("View",
fyne.NewMenuItem("Playlist", func() {
// Will be implemented with playlist toggle
}),
)
if len(s.loadedVideos) >= 2 {
viewMenu.Items = append(viewMenu.Items, fyne.NewMenuItem("Compare Videos", func() {
s.showCompareView()
}))
}
toolsMenu := fyne.NewMenu("Tools")
// Keyframing mode toggle
keyframeModeItem := fyne.NewMenuItem("Frame-Accurate Mode", func() {
s.keyframingMode = !s.keyframingMode
refresh()
})
keyframeModeItem.Checked = s.keyframingMode
toolsMenu.Items = append(toolsMenu.Items, keyframeModeItem)
mainMenu := fyne.NewMainMenu(fileMenu, viewMenu, toolsMenu)
s.window.SetMainMenu(mainMenu)
// Player area
var playerArea fyne.CanvasObject
if s.source == nil {
bg := canvas.NewRectangle(utils.MustHex("#05070C"))
bg.SetMinSize(fyne.NewSize(960, 540))
// Minimal play icon stack
outer := canvas.NewCircle(utils.MustHex("#2A1540"))
outer.Resize(fyne.NewSize(120, 120))
middle := canvas.NewCircle(utils.MustHex("#5A2E7A"))
middle.Resize(fyne.NewSize(96, 96))
inner := canvas.NewCircle(utils.MustHex("#7F45B0"))
inner.Resize(fyne.NewSize(74, 74))
playTri := canvas.NewPolygon(3, color.NRGBA{R: 232, G: 239, B: 255, A: 255})
playTri.StrokeColor = color.Transparent
playTri.Resize(fyne.NewSize(36, 36))
icon := container.NewMax(
outer,
container.NewCenter(middle),
container.NewCenter(inner),
container.NewCenter(playTri),
)
loadBtn := widget.NewButton("Load Video", func() {
dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil || r == nil {
return
}
path := r.URI().Path()
r.Close()
go s.loadVideo(path)
}, s.window)
dlg.Resize(fyne.NewSize(700, 480))
dlg.Show()
})
loadBtn.Importance = widget.HighImportance
hint := widget.NewLabel("Drop files or URLs to play here.")
hint.Alignment = fyne.TextAlignCenter
centerStack := container.NewVBox(
container.NewCenter(icon),
container.NewCenter(loadBtn),
container.NewCenter(hint),
)
playerArea = container.NewMax(bg, container.NewCenter(centerStack))
} else {
src := s.source
// Image surface
stageBG := canvas.NewRectangle(utils.MustHex("#0F1529"))
stageBG.SetMinSize(fyne.NewSize(960, 540))
videoImg := canvas.NewImageFromResource(nil)
videoImg.FillMode = canvas.ImageFillContain
// Load initial preview frame if available
if s.currentFrame != "" {
if img, err := fyne.LoadResourceFromPath(s.currentFrame); err == nil {
videoImg.Resource = img
}
}
stage := container.NewMax(stageBG, videoImg)
// Playlist panel (only when we have media loaded) - now on the right
playlist := widget.NewList(
func() int { return len(s.loadedVideos) },
func() fyne.CanvasObject { return widget.NewLabel("item") },
func(id widget.ListItemID, o fyne.CanvasObject) {
if id >= 0 && id < len(s.loadedVideos) {
o.(*widget.Label).SetText(filepath.Base(s.loadedVideos[id].Path))
}
},
)
playlist.OnSelected = func(id widget.ListItemID) {
if id >= 0 && id < len(s.loadedVideos) {
s.switchToVideo(id)
}
}
if len(s.loadedVideos) > 0 && s.currentIndex < len(s.loadedVideos) {
playlist.Select(s.currentIndex)
}
// Playlist with toggle visibility
playlistContainer := container.NewBorder(
widget.NewLabelWithStyle("Playlist", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
nil, nil, nil,
playlist,
)
playlistContainer.Resize(fyne.NewSize(250, 540))
var playlistVisible bool = len(s.loadedVideos) > 1
var mainContent *fyne.Container
if playlistVisible {
mainContent = container.NewBorder(nil, nil, nil, playlistContainer, container.NewPadded(stage))
} else {
mainContent = container.NewPadded(stage)
}
currentTime := widget.NewLabel("0:00")
totalTime := widget.NewLabel(src.DurationString())
totalTime.Alignment = fyne.TextAlignTrailing
slider := widget.NewSlider(0, math.Max(1, src.Duration))
slider.Step = 0.5
var updatingProgress bool
updateProgress := func(val float64) {
fyne.Do(func() {
updatingProgress = true
currentTime.SetText(formatClock(val))
slider.SetValue(val)
updatingProgress = false
})
}
var ensureSession func() bool
ensureSession = func() bool {
if s.playSess == nil {
s.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, 960, 540, updateProgress, videoImg)
s.playSess.SetVolume(s.playerVolume)
s.playerPaused = true
}
return s.playSess != nil
}
slider.OnChanged = func(val float64) {
if updatingProgress {
return
}
updateProgress(val)
if ensureSession() {
s.playSess.Seek(val)
}
}
var playBtn *widget.Button
playBtn = ui.NewIconButton(ui.IconPlayArrow, "Play/Pause", func() {
if !ensureSession() {
return
}
if s.playerPaused {
s.playSess.Play()
s.playerPaused = false
playBtn.SetText(ui.IconPause)
} else {
s.playSess.Pause()
s.playerPaused = true
playBtn.SetText(ui.IconPlayArrow)
}
})
prevBtn := ui.NewIconButton(ui.IconSkipPrevious, "Previous", func() {
s.prevVideo()
})
nextBtn := ui.NewIconButton(ui.IconSkipNext, "Next", func() {
s.nextVideo()
})
var volIcon *widget.Button
volIcon = ui.NewIconButton(ui.IconVolumeUp, "Mute/Unmute", func() {
if !ensureSession() {
return
}
if s.playerMuted {
target := s.lastVolume
if target <= 0 {
target = 50
}
s.playerVolume = target
s.playerMuted = false
s.playSess.SetVolume(target)
} else {
s.lastVolume = s.playerVolume
s.playerVolume = 0
s.playerMuted = true
s.playSess.SetVolume(0)
}
volIcon.SetText(ui.GetVolumeIcon(s.playerVolume, s.playerMuted))
})
volSlider := widget.NewSlider(0, 100)
volSlider.Step = 1
volSlider.Value = s.playerVolume
volSlider.OnChanged = func(val float64) {
s.playerVolume = val
if val > 0 {
s.lastVolume = val
s.playerMuted = false
} else {
s.playerMuted = true
}
if ensureSession() {
s.playSess.SetVolume(val)
}
volIcon.SetText(ui.GetVolumeIcon(s.playerVolume, s.playerMuted))
}
// Playlist toggle button
playlistToggleBtn := ui.NewIconButton(ui.IconMenu, "Toggle Playlist", func() {
playlistVisible = !playlistVisible
if playlistVisible {
mainContent.Objects = []fyne.CanvasObject{container.NewPadded(stage)}
mainContent.Objects = []fyne.CanvasObject{}
*mainContent = *container.NewBorder(nil, nil, nil, playlistContainer, container.NewPadded(stage))
} else {
mainContent.Objects = []fyne.CanvasObject{}
*mainContent = *container.NewPadded(stage)
}
mainContent.Refresh()
})
progressBar := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
volContainer := container.NewHBox(volIcon, container.NewMax(volSlider))
volContainer.Resize(fyne.NewSize(150, 32))
// Center the playback controls
playbackControls := container.NewHBox(prevBtn, playBtn, nextBtn)
// Create control row with centered playback controls
controlRow := container.NewBorder(
nil, nil,
volContainer, // Volume on left
container.NewHBox(playlistToggleBtn), // Playlist toggle on right
container.NewCenter(playbackControls), // Playback controls centered
)
playerArea = container.NewBorder(
nil,
container.NewVBox(container.NewPadded(progressBar), container.NewPadded(controlRow)),
nil,
nil,
mainContent,
)
}
mainPanel := playerArea
s.setContent(mainPanel)
}
// Legacy queue view left in place but not used in player-only mode.
func (s *appState) showQueue() {}
func (s *appState) refreshQueueView() {}
// addConvertToQueue adds a conversion job to the queue
func (s *appState) addConvertToQueue() error {
if s.source == nil {
return fmt.Errorf("no video loaded")
}
src := s.source
cfg := s.convert
outDir := filepath.Dir(src.Path)
outName := cfg.OutputFile()
if outName == "" {
outName = "converted" + cfg.SelectedFormat.Ext
}
outPath := filepath.Join(outDir, outName)
if outPath == src.Path {
outPath = filepath.Join(outDir, "converted-"+outName)
}
// Create job config map
config := map[string]interface{}{
"inputPath": src.Path,
"outputPath": outPath,
"outputBase": cfg.OutputBase,
"selectedFormat": cfg.SelectedFormat,
"quality": cfg.Quality,
"mode": cfg.Mode,
"videoCodec": cfg.VideoCodec,
"encoderPreset": cfg.EncoderPreset,
"crf": cfg.CRF,
"bitrateMode": cfg.BitrateMode,
"videoBitrate": cfg.VideoBitrate,
"targetFileSize": cfg.TargetFileSize,
"targetResolution": cfg.TargetResolution,
"frameRate": cfg.FrameRate,
"pixelFormat": cfg.PixelFormat,
"hardwareAccel": cfg.HardwareAccel,
"twoPass": cfg.TwoPass,
"h264Profile": cfg.H264Profile,
"h264Level": cfg.H264Level,
"deinterlace": cfg.Deinterlace,
"deinterlaceMethod": cfg.DeinterlaceMethod,
"autoCrop": cfg.AutoCrop,
"cropWidth": cfg.CropWidth,
"cropHeight": cfg.CropHeight,
"cropX": cfg.CropX,
"cropY": cfg.CropY,
"audioCodec": cfg.AudioCodec,
"audioBitrate": cfg.AudioBitrate,
"audioChannels": cfg.AudioChannels,
"audioSampleRate": cfg.AudioSampleRate,
"normalizeAudio": cfg.NormalizeAudio,
"inverseTelecine": cfg.InverseTelecine,
"coverArtPath": cfg.CoverArtPath,
"aspectHandling": cfg.AspectHandling,
"outputAspect": cfg.OutputAspect,
"sourceWidth": src.Width,
"sourceHeight": src.Height,
"sourceDuration": src.Duration,
"fieldOrder": src.FieldOrder,
}
job := &queue.Job{
Type: queue.JobTypeConvert,
Title: fmt.Sprintf("Convert %s", filepath.Base(src.Path)),
Description: fmt.Sprintf("Output: %s → %s", filepath.Base(src.Path), filepath.Base(outPath)),
InputFile: src.Path,
OutputFile: outPath,
Config: config,
}
s.jobQueue.Add(job)
logging.Debug(logging.CatSystem, "added convert job to queue: %s", job.ID)
return nil
}
func (s *appState) showModule(id string) {
switch id {
case "player":
s.showPlayerView()
default:
logging.Debug(logging.CatUI, "UI module %s not wired yet", id)
}
}
func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
logging.Debug(logging.CatModule, "handleModuleDrop called: moduleID=%s itemCount=%d", moduleID, len(items))
if len(items) == 0 {
logging.Debug(logging.CatModule, "handleModuleDrop: no items to process")
return
}
// Collect all video files (including from folders)
var videoPaths []string
for _, uri := range items {
logging.Debug(logging.CatModule, "handleModuleDrop: processing uri scheme=%s path=%s", uri.Scheme(), uri.Path())
if uri.Scheme() != "file" {
logging.Debug(logging.CatModule, "handleModuleDrop: skipping non-file URI")
continue
}
path := uri.Path()
// Check if it's a directory
if info, err := os.Stat(path); err == nil && info.IsDir() {
logging.Debug(logging.CatModule, "processing directory: %s", path)
videos := s.findVideoFiles(path)
videoPaths = append(videoPaths, videos...)
} else if s.isVideoFile(path) {
videoPaths = append(videoPaths, path)
}
}
logging.Debug(logging.CatModule, "found %d video files to process", len(videoPaths))
if len(videoPaths) == 0 {
return
}
// Player: if multiple files, load as playlist; otherwise load single.
if len(videoPaths) > 1 {
go s.loadVideos(videoPaths)
return
}
path := videoPaths[0]
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
go s.loadVideo(path)
}
// isVideoFile checks if a file has a video extension
func (s *appState) isVideoFile(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
videoExts := []string{".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp", ".ogv"}
for _, videoExt := range videoExts {
if ext == videoExt {
return true
}
}
return false
}
// findVideoFiles recursively finds all video files in a directory
func (s *appState) findVideoFiles(dir string) []string {
var videos []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
}
if !info.IsDir() && s.isVideoFile(path) {
videos = append(videos, path)
}
return nil
})
if err != nil {
logging.Debug(logging.CatModule, "error walking directory %s: %v", dir, err)
}
return videos
}
// batchAddToQueue adds multiple videos to the queue
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))
outName := baseName + "-converted" + s.convert.SelectedFormat.Ext
outPath := filepath.Join(outDir, outName)
config := map[string]interface{}{
"inputPath": path,
"outputPath": outPath,
"outputBase": baseName + "-converted",
"selectedFormat": s.convert.SelectedFormat,
"quality": s.convert.Quality,
"mode": s.convert.Mode,
"videoCodec": s.convert.VideoCodec,
"encoderPreset": s.convert.EncoderPreset,
"crf": s.convert.CRF,
"bitrateMode": s.convert.BitrateMode,
"videoBitrate": s.convert.VideoBitrate,
"targetResolution": s.convert.TargetResolution,
"frameRate": s.convert.FrameRate,
"pixelFormat": s.convert.PixelFormat,
"hardwareAccel": s.convert.HardwareAccel,
"twoPass": s.convert.TwoPass,
"h264Profile": s.convert.H264Profile,
"h264Level": s.convert.H264Level,
"deinterlace": s.convert.Deinterlace,
"deinterlaceMethod": s.convert.DeinterlaceMethod,
"audioCodec": s.convert.AudioCodec,
"audioBitrate": s.convert.AudioBitrate,
"audioChannels": s.convert.AudioChannels,
"audioSampleRate": s.convert.AudioSampleRate,
"normalizeAudio": s.convert.NormalizeAudio,
"inverseTelecine": s.convert.InverseTelecine,
"coverArtPath": "",
"aspectHandling": s.convert.AspectHandling,
"outputAspect": s.convert.OutputAspect,
"sourceWidth": src.Width,
"sourceHeight": src.Height,
"sourceDuration": src.Duration,
"fieldOrder": src.FieldOrder,
}
job := &queue.Job{
Type: queue.JobTypeConvert,
Title: fmt.Sprintf("Convert %s", filepath.Base(path)),
Description: fmt.Sprintf("Output: %s → %s", filepath.Base(path), filepath.Base(outPath)),
InputFile: path,
OutputFile: outPath,
Config: config,
}
s.jobQueue.Add(job)
addedCount++
}
// Show confirmation dialog
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
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 all valid videos so user can navigate between them
if firstValidPath != "" {
combined := make([]string, 0, len(s.loadedVideos)+len(paths))
seen := make(map[string]bool)
for _, v := range s.loadedVideos {
if v != nil && !seen[v.Path] {
combined = append(combined, v.Path)
seen[v.Path] = true
}
}
for _, p := range paths {
if !seen[p] {
combined = append(combined, p)
seen[p] = true
}
}
s.loadVideos(combined)
s.showPlayerView()
}
}, false)
}
// jobExecutor executes a job from the queue
func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
logging.Debug(logging.CatSystem, "executing job %s: %s", job.ID, job.Title)
switch job.Type {
case queue.JobTypeConvert:
return s.executeConvertJob(ctx, job, progressCallback)
case queue.JobTypeMerge:
return fmt.Errorf("merge jobs not yet implemented")
case queue.JobTypeTrim:
return fmt.Errorf("trim jobs not yet implemented")
case queue.JobTypeFilter:
return fmt.Errorf("filter jobs not yet implemented")
case queue.JobTypeUpscale:
return fmt.Errorf("upscale jobs not yet implemented")
case queue.JobTypeAudio:
return fmt.Errorf("audio jobs not yet implemented")
case queue.JobTypeThumb:
return fmt.Errorf("thumb jobs not yet implemented")
default:
return fmt.Errorf("unknown job type: %s", job.Type)
}
}
// executeConvertJob executes a conversion job from the queue
func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config
inputPath := cfg["inputPath"].(string)
outputPath := cfg["outputPath"].(string)
// If a direct conversion is running, wait until it finishes before starting queued jobs.
for s.convertBusy {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
// Build FFmpeg arguments
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
}
// Check if this is a DVD format (special handling required)
selectedFormat, _ := cfg["selectedFormat"].(formatOption)
isDVD := selectedFormat.Ext == ".mpg"
var targetOption string
// DVD presets: enforce compliant target, frame rate, resolution, codecs
if isDVD {
if strings.Contains(selectedFormat.Label, "PAL") {
targetOption = "pal-dvd"
cfg["frameRate"] = "25"
cfg["targetResolution"] = "PAL (720×576)"
} else {
targetOption = "ntsc-dvd"
cfg["frameRate"] = "29.97"
cfg["targetResolution"] = "NTSC (720×480)"
}
cfg["videoCodec"] = "MPEG-2"
cfg["audioCodec"] = "AC-3"
if _, ok := cfg["audioBitrate"].(string); !ok || cfg["audioBitrate"] == "" {
cfg["audioBitrate"] = "192k"
}
cfg["pixelFormat"] = "yuv420p"
}
args = append(args, "-i", inputPath)
// Add cover art if available
coverArtPath, _ := cfg["coverArtPath"].(string)
hasCoverArt := coverArtPath != ""
if isDVD {
// DVD targets do not support attached cover art
hasCoverArt = false
}
if hasCoverArt {
args = append(args, "-i", coverArtPath)
}
// Hardware acceleration for decoding
// Note: NVENC doesn't need -hwaccel for encoding, only for decoding
hardwareAccel, _ := cfg["hardwareAccel"].(string)
if hardwareAccel != "none" && hardwareAccel != "" {
switch hardwareAccel {
case "nvenc":
// For NVENC, we don't add -hwaccel flags
// The h264_nvenc/hevc_nvenc encoder handles GPU encoding directly
// Only add hwaccel if we want GPU decoding too, which can cause issues
case "vaapi":
args = append(args, "-hwaccel", "vaapi")
case "qsv":
args = append(args, "-hwaccel", "qsv")
case "videotoolbox":
args = append(args, "-hwaccel", "videotoolbox")
}
}
// Video filters
var vf []string
// Deinterlacing
shouldDeinterlace := false
deinterlaceMode, _ := cfg["deinterlace"].(string)
fieldOrder, _ := cfg["fieldOrder"].(string)
if deinterlaceMode == "Force" {
shouldDeinterlace = true
} else if deinterlaceMode == "Auto" || deinterlaceMode == "" {
// Auto-detect based on field order
if fieldOrder != "" && fieldOrder != "progressive" && fieldOrder != "unknown" {
shouldDeinterlace = true
}
}
// Legacy support
if inverseTelecine, _ := cfg["inverseTelecine"].(bool); inverseTelecine {
shouldDeinterlace = true
}
if shouldDeinterlace {
// Choose deinterlacing method
deintMethod, _ := cfg["deinterlaceMethod"].(string)
if deintMethod == "" {
deintMethod = "bwdif" // Default to bwdif (higher quality)
}
if deintMethod == "bwdif" {
vf = append(vf, "bwdif=mode=send_frame:parity=auto")
} else {
vf = append(vf, "yadif=0:-1:0")
}
}
// Auto-crop black bars (apply before scaling for best results)
if autoCrop, _ := cfg["autoCrop"].(bool); autoCrop {
cropWidth, _ := cfg["cropWidth"].(string)
cropHeight, _ := cfg["cropHeight"].(string)
cropX, _ := cfg["cropX"].(string)
cropY, _ := cfg["cropY"].(string)
if cropWidth != "" && cropHeight != "" {
cropW := strings.TrimSpace(cropWidth)
cropH := strings.TrimSpace(cropHeight)
cropXStr := strings.TrimSpace(cropX)
cropYStr := strings.TrimSpace(cropY)
// Default to center crop if X/Y not specified
if cropXStr == "" {
cropXStr = "(in_w-out_w)/2"
}
if cropYStr == "" {
cropYStr = "(in_h-out_h)/2"
}
cropFilter := fmt.Sprintf("crop=%s:%s:%s:%s", cropW, cropH, cropXStr, cropYStr)
vf = append(vf, cropFilter)
logging.Debug(logging.CatFFMPEG, "applying crop in queue job: %s", cropFilter)
}
}
// Scaling/Resolution
targetResolution, _ := cfg["targetResolution"].(string)
if targetResolution != "" && targetResolution != "Source" {
var scaleFilter string
switch targetResolution {
case "720p":
scaleFilter = "scale=-2:720"
case "1080p":
scaleFilter = "scale=-2:1080"
case "1440p":
scaleFilter = "scale=-2:1440"
case "4K":
scaleFilter = "scale=-2:2160"
case "8K":
scaleFilter = "scale=-2:4320"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Aspect ratio conversion
sourceWidth, _ := cfg["sourceWidth"].(int)
sourceHeight, _ := cfg["sourceHeight"].(int)
srcAspect := utils.AspectRatioFloat(sourceWidth, sourceHeight)
outputAspect, _ := cfg["outputAspect"].(string)
aspectHandling, _ := cfg["aspectHandling"].(string)
// Create temp source for aspect calculation
tempSrc := &videoSource{Width: sourceWidth, Height: sourceHeight}
targetAspect := resolveTargetAspect(outputAspect, tempSrc)
if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) {
vf = append(vf, aspectFilters(targetAspect, aspectHandling)...)
}
// Frame rate
frameRate, _ := cfg["frameRate"].(string)
if frameRate != "" && frameRate != "Source" {
vf = append(vf, "fps="+frameRate)
}
if len(vf) > 0 {
args = append(args, "-vf", strings.Join(vf, ","))
}
// Video codec
videoCodec, _ := cfg["videoCodec"].(string)
if videoCodec == "Copy" && !isDVD {
args = append(args, "-c:v", "copy")
} else {
// Determine the actual codec to use
var actualCodec string
if isDVD {
// DVD requires MPEG-2 video
actualCodec = "mpeg2video"
} else {
actualCodec = determineVideoCodec(convertConfig{
VideoCodec: videoCodec,
HardwareAccel: hardwareAccel,
})
}
args = append(args, "-c:v", actualCodec)
// DVD-specific video settings
if isDVD {
// NTSC vs PAL settings
if strings.Contains(selectedFormat.Label, "NTSC") {
args = append(args, "-b:v", "6000k", "-maxrate", "9000k", "-bufsize", "1835k", "-g", "15")
} else if strings.Contains(selectedFormat.Label, "PAL") {
args = append(args, "-b:v", "8000k", "-maxrate", "9500k", "-bufsize", "2228k", "-g", "12")
}
} else {
// Standard bitrate mode and quality for non-DVD
bitrateMode, _ := cfg["bitrateMode"].(string)
if bitrateMode == "CRF" || bitrateMode == "" {
crfStr, _ := cfg["crf"].(string)
if crfStr == "" {
quality, _ := cfg["quality"].(string)
crfStr = crfForQuality(quality)
}
if actualCodec == "libx264" || actualCodec == "libx265" || actualCodec == "libvpx-vp9" {
args = append(args, "-crf", crfStr)
}
} else if bitrateMode == "CBR" {
if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" {
args = append(args, "-b:v", videoBitrate, "-minrate", videoBitrate, "-maxrate", videoBitrate, "-bufsize", videoBitrate)
}
} else if bitrateMode == "VBR" {
if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" {
args = append(args, "-b:v", videoBitrate)
}
} else if bitrateMode == "Target Size" {
// Calculate bitrate from target file size
targetSizeStr, _ := cfg["targetFileSize"].(string)
audioBitrateStr, _ := cfg["audioBitrate"].(string)
duration, _ := cfg["sourceDuration"].(float64)
if targetSizeStr != "" && duration > 0 {
targetBytes, err := convert.ParseFileSize(targetSizeStr)
if err == nil {
// Parse audio bitrate (default to 192k if not set)
audioBitrate := 192000
if audioBitrateStr != "" {
if rate, err := utils.ParseInt(strings.TrimSuffix(audioBitrateStr, "k")); err == nil {
audioBitrate = rate * 1000
}
}
// Calculate required video bitrate
videoBitrate := convert.CalculateBitrateForTargetSize(targetBytes, duration, audioBitrate)
videoBitrateStr := fmt.Sprintf("%dk", videoBitrate/1000)
logging.Debug(logging.CatFFMPEG, "target size mode: %s -> video bitrate %s (audio %s)", targetSizeStr, videoBitrateStr, audioBitrateStr)
args = append(args, "-b:v", videoBitrateStr)
}
}
}
// Encoder preset
if encoderPreset, _ := cfg["encoderPreset"].(string); encoderPreset != "" && (actualCodec == "libx264" || actualCodec == "libx265") {
args = append(args, "-preset", encoderPreset)
}
// Pixel format
if pixelFormat, _ := cfg["pixelFormat"].(string); pixelFormat != "" {
args = append(args, "-pix_fmt", pixelFormat)
}
// H.264 profile and level for compatibility
if videoCodec == "H.264" && (strings.Contains(actualCodec, "264") || strings.Contains(actualCodec, "h264")) {
if h264Profile, _ := cfg["h264Profile"].(string); h264Profile != "" && h264Profile != "Auto" {
// Use :v:0 if cover art is present to avoid applying to PNG stream
if hasCoverArt {
args = append(args, "-profile:v:0", h264Profile)
} else {
args = append(args, "-profile:v", h264Profile)
}
}
if h264Level, _ := cfg["h264Level"].(string); h264Level != "" && h264Level != "Auto" {
if hasCoverArt {
args = append(args, "-level:v:0", h264Level)
} else {
args = append(args, "-level:v", h264Level)
}
}
}
}
}
// Audio codec and settings
audioCodec, _ := cfg["audioCodec"].(string)
if audioCodec == "Copy" && !isDVD {
args = append(args, "-c:a", "copy")
} else {
var actualAudioCodec string
if isDVD {
// DVD requires AC-3 audio
actualAudioCodec = "ac3"
} else {
actualAudioCodec = determineAudioCodec(convertConfig{AudioCodec: audioCodec})
}
args = append(args, "-c:a", actualAudioCodec)
// DVD-specific audio settings
if isDVD {
// DVD standard: AC-3 stereo at 48 kHz, 192 kbps
args = append(args, "-b:a", "192k", "-ar", "48000", "-ac", "2")
} else {
// Standard audio settings for non-DVD
if audioBitrate, _ := cfg["audioBitrate"].(string); audioBitrate != "" && actualAudioCodec != "flac" {
args = append(args, "-b:a", audioBitrate)
}
// Audio normalization (compatibility mode)
if normalizeAudio, _ := cfg["normalizeAudio"].(bool); normalizeAudio {
args = append(args, "-ac", "2", "-ar", "48000")
} else {
if audioChannels, _ := cfg["audioChannels"].(string); audioChannels != "" && audioChannels != "Source" {
switch audioChannels {
case "Mono":
args = append(args, "-ac", "1")
case "Stereo":
args = append(args, "-ac", "2")
case "5.1":
args = append(args, "-ac", "6")
}
}
if audioSampleRate, _ := cfg["audioSampleRate"].(string); audioSampleRate != "" && audioSampleRate != "Source" {
args = append(args, "-ar", audioSampleRate)
}
}
}
}
// Map cover art
if hasCoverArt {
args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v")
args = append(args, "-c:v:1", "png")
args = append(args, "-disposition:v:1", "attached_pic")
}
// Format-specific settings (already parsed above for DVD check)
switch v := cfg["selectedFormat"].(type) {
case formatOption:
selectedFormat = v
case map[string]interface{}:
// Reconstruct from map (happens when loading from JSON)
if label, ok := v["Label"].(string); ok {
selectedFormat.Label = label
}
if ext, ok := v["Ext"].(string); ok {
selectedFormat.Ext = ext
}
if codec, ok := v["VideoCodec"].(string); ok {
selectedFormat.VideoCodec = codec
}
default:
// Fallback to MP4
selectedFormat = formatOptions[0]
}
if strings.EqualFold(selectedFormat.Ext, ".mp4") || strings.EqualFold(selectedFormat.Ext, ".mov") {
args = append(args, "-movflags", "+faststart")
}
if targetOption != "" {
args = append(args, "-target", targetOption)
}
// Fix VFR/desync issues - regenerate timestamps and enforce CFR
args = append(args, "-fflags", "+genpts")
frameRateStr, _ := cfg["frameRate"].(string)
sourceDuration, _ := cfg["sourceDuration"].(float64)
if frameRateStr != "" && frameRateStr != "Source" {
args = append(args, "-r", frameRateStr)
} else if sourceDuration > 0 {
// Calculate approximate source frame rate if available
args = append(args, "-r", "30") // Safe default
}
// Progress feed
args = append(args, "-progress", "pipe:1", "-nostats")
args = append(args, outputPath)
logging.Debug(logging.CatFFMPEG, "queue convert command: ffmpeg %s", strings.Join(args, " "))
// Also print to stdout for debugging
fmt.Printf("\n=== FFMPEG COMMAND ===\nffmpeg %s\n======================\n\n", strings.Join(args, " "))
// Execute FFmpeg
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
// Capture stderr for error messages
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err)
}
// Parse progress
scanner := bufio.NewScanner(stdout)
var duration float64
if d, ok := cfg["sourceDuration"].(float64); ok && d > 0 {
duration = d
}
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "out_time_ms=") {
val := strings.TrimPrefix(line, "out_time_ms=")
if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 {
currentSec := float64(ms) / 1000000.0
if duration > 0 {
progress := (currentSec / duration) * 100.0
if progress > 100 {
progress = 100
}
progressCallback(progress)
}
}
} else if strings.HasPrefix(line, "duration_ms=") {
val := strings.TrimPrefix(line, "duration_ms=")
if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 {
duration = float64(ms) / 1000000.0
}
}
}
if err := cmd.Wait(); err != nil {
stderrOutput := stderrBuf.String()
errorExplanation := interpretFFmpegError(err)
// Check if this is a hardware encoding failure
isHardwareFailure := strings.Contains(stderrOutput, "No capable devices found") ||
strings.Contains(stderrOutput, "Cannot load") ||
strings.Contains(stderrOutput, "not available") &&
(strings.Contains(stderrOutput, "nvenc") ||
strings.Contains(stderrOutput, "qsv") ||
strings.Contains(stderrOutput, "vaapi") ||
strings.Contains(stderrOutput, "videotoolbox"))
if isHardwareFailure && hardwareAccel != "none" && hardwareAccel != "" {
logging.Debug(logging.CatFFMPEG, "hardware encoding failed, will suggest software fallback")
return fmt.Errorf("hardware encoding (%s) failed - no compatible hardware found\n\nPlease disable hardware acceleration in the conversion settings and try again with software encoding.\n\nFFmpeg output:\n%s", hardwareAccel, stderrOutput)
}
var errorMsg string
if errorExplanation != "" {
errorMsg = fmt.Sprintf("ffmpeg failed: %v - %s", err, errorExplanation)
} else {
errorMsg = fmt.Sprintf("ffmpeg failed: %v", err)
}
if stderrOutput != "" {
logging.Debug(logging.CatFFMPEG, "ffmpeg stderr: %s", stderrOutput)
return fmt.Errorf("%s\n\nFFmpeg output:\n%s", errorMsg, stderrOutput)
}
return fmt.Errorf("%s", errorMsg)
}
logging.Debug(logging.CatFFMPEG, "queue conversion completed: %s", outputPath)
return nil
}
func (s *appState) shutdown() {
// Stop queue without saving - we want a clean slate each session
if s.jobQueue != nil {
s.jobQueue.Stop()
}
s.stopPlayer()
s.stopCompareSessions()
if s.player != nil {
s.player.Close()
}
}
func (s *appState) stopPlayer() {
if s.playSess != nil {
s.playSess.Stop()
s.playSess = nil
}
if s.player != nil {
s.player.Stop()
}
s.stopProgressLoop()
s.playerReady = false
s.playerPaused = true
}
func (s *appState) stopCompareSessions() {
if s.compareSess1 != nil {
s.compareSess1.Stop()
s.compareSess1 = nil
}
if s.compareSess2 != nil {
s.compareSess2.Stop()
s.compareSess2 = nil
}
}
func main() {
logging.Init()
defer logging.Close()
flag.Parse()
logging.SetDebug(*debugFlag || os.Getenv("VIDEOTOOLS_DEBUG") != "")
logging.Debug(logging.CatSystem, "starting VideoTools prototype at %s", time.Now().Format(time.RFC3339))
args := flag.Args()
if len(args) > 0 {
if err := runCLI(args); err != nil {
fmt.Fprintln(os.Stderr, "videotools:", err)
fmt.Fprintln(os.Stderr)
printUsage()
os.Exit(1)
}
return
}
// Detect display server (X11 or Wayland)
display := os.Getenv("DISPLAY")
waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
xdgSessionType := os.Getenv("XDG_SESSION_TYPE")
if waylandDisplay != "" {
logging.Debug(logging.CatUI, "Wayland display server detected: WAYLAND_DISPLAY=%s", waylandDisplay)
} else if display != "" {
logging.Debug(logging.CatUI, "X11 display server detected: DISPLAY=%s", display)
} else {
logging.Debug(logging.CatUI, "No display server detected (DISPLAY and WAYLAND_DISPLAY are empty); GUI may not be visible in headless mode")
}
if xdgSessionType != "" {
logging.Debug(logging.CatUI, "Session type: %s", xdgSessionType)
}
runGUI()
}
func runGUI() {
// Initialize UI colors
ui.SetColors(gridColor, textColor)
a := app.NewWithID("com.leaktechnologies.vtplayer")
// Always start with a clean slate: wipe any persisted app storage (queue or otherwise)
if root := a.Storage().RootURI(); root != nil && root.Scheme() == "file" {
_ = os.RemoveAll(root.Path())
}
a.Settings().SetTheme(&ui.MonoTheme{})
logging.Debug(logging.CatUI, "created fyne app: %#v", a)
w := a.NewWindow("VT Player")
if icon := utils.LoadAppIcon(); icon != nil {
a.SetIcon(icon)
w.SetIcon(icon)
logging.Debug(logging.CatUI, "app icon loaded and applied")
} else {
logging.Debug(logging.CatUI, "app icon not found; continuing without custom icon")
}
// Use a generous default window size that fits typical desktops without overflowing.
w.Resize(fyne.NewSize(1280, 800))
w.SetFixedSize(false) // Allow manual resizing
w.CenterOnScreen()
logging.Debug(logging.CatUI, "window initialized with manual resizing and centering enabled")
state := &appState{
window: w,
convert: convertConfig{
OutputBase: "converted",
SelectedFormat: formatOptions[0],
Quality: "Standard (CRF 23)",
Mode: "Simple",
// Video encoding defaults
VideoCodec: "H.264",
EncoderPreset: "medium",
CRF: "", // Empty means use Quality preset
BitrateMode: "CRF",
VideoBitrate: "5000k",
TargetResolution: "Source",
FrameRate: "Source",
PixelFormat: "yuv420p",
HardwareAccel: "none",
TwoPass: false,
H264Profile: "main",
H264Level: "4.0",
Deinterlace: "Auto",
DeinterlaceMethod: "bwdif",
AutoCrop: false,
// Audio encoding defaults
AudioCodec: "AAC",
AudioBitrate: "192k",
AudioChannels: "Source",
AudioSampleRate: "Source",
NormalizeAudio: false,
// Other defaults
InverseTelecine: true,
InverseAutoNotes: "Default smoothing for interlaced footage.",
OutputAspect: "Source",
AspectHandling: "Auto",
},
player: player.New(),
playerVolume: 100,
lastVolume: 100,
playerMuted: false,
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() {
app := fyne.CurrentApp()
if app == nil || app.Driver() == nil {
return
}
app.Driver().DoFromGoroutine(func() {
state.updateStatsBar()
state.updateQueueButtonLabel()
if state.active == "queue" {
state.refreshQueueView()
}
}, false)
})
defer state.shutdown()
w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) {
state.handleDropPlayer(items)
})
state.showMainMenu()
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
// Start stats bar update loop on a timer
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
state.updateStatsBar()
}, false)
}
}
}()
w.ShowAndRun()
}
func runCLI(args []string) error {
cmd := strings.ToLower(args[0])
cmdArgs := args[1:]
logging.Debug(logging.CatCLI, "command=%s args=%v", cmd, cmdArgs)
switch cmd {
case "convert":
return runConvertCLI(cmdArgs)
case "combine", "merge":
return runCombineCLI(cmdArgs)
case "trim":
modules.HandleTrim(cmdArgs)
case "filters":
modules.HandleFilters(cmdArgs)
case "upscale":
modules.HandleUpscale(cmdArgs)
case "audio":
modules.HandleAudio(cmdArgs)
case "thumb":
modules.HandleThumb(cmdArgs)
case "compare":
modules.HandleCompare(cmdArgs)
case "inspect":
modules.HandleInspect(cmdArgs)
case "logs":
return runLogsCLI()
case "help":
printUsage()
default:
return fmt.Errorf("unknown command %q", cmd)
}
return nil
}
func runConvertCLI(args []string) error {
if len(args) < 2 {
return fmt.Errorf("convert requires input and output files (e.g. videotools convert input.avi output.mp4)")
}
in, out := args[0], args[1]
logging.Debug(logging.CatFFMPEG, "convert input=%s output=%s", in, out)
modules.HandleConvert([]string{in, out})
return nil
}
func runCombineCLI(args []string) error {
if len(args) == 0 {
return fmt.Errorf("combine requires input files and an output (e.g. videotools combine clip1.mov clip2.wav / final.mp4)")
}
inputs, outputs, err := splitIOArgs(args)
if err != nil {
return err
}
if len(inputs) == 0 || len(outputs) == 0 {
return fmt.Errorf("combine expects one or more inputs, '/', then an output file")
}
logging.Debug(logging.CatFFMPEG, "combine inputs=%v output=%v", inputs, outputs)
// For now feed inputs followed by outputs to the merge handler.
modules.HandleMerge(append(inputs, outputs...))
return nil
}
func splitIOArgs(args []string) (inputs []string, outputs []string, err error) {
sep := -1
for i, a := range args {
if a == "/" {
sep = i
break
}
}
if sep == -1 {
return nil, nil, fmt.Errorf("missing '/' separator between inputs and outputs")
}
inputs = append(inputs, args[:sep]...)
outputs = append(outputs, args[sep+1:]...)
return inputs, outputs, nil
}
func printUsage() {
fmt.Println("Usage:")
fmt.Println(" videotools convert