Show preview frame when loading videos

This commit is contained in:
Stu 2025-12-09 18:20:36 -05:00
parent c4a5e48a22
commit 8815f69fe8

193
main.go
View File

@ -379,18 +379,22 @@ func (s *appState) applyInverseDefaults(src *videoSource) {
}
func (s *appState) setContent(body fyne.CanvasObject) {
fmt.Printf("🎨 setContent called with body: %v\n", body != nil)
update := func() {
bg := canvas.NewRectangle(backgroundColor)
// Don't set a minimum size - let content determine layout naturally
if body == nil {
fmt.Printf("🎨 Setting empty background\n")
s.window.SetContent(bg)
return
}
fmt.Printf("🎨 Setting window content with body\n")
s.window.SetContent(container.NewMax(bg, body))
}
// Use async Do() instead of DoAndWait() to avoid deadlock when called from main goroutine
fyne.Do(update)
fmt.Printf("🎨 setContent completed\n")
}
// showErrorWithCopy displays an error dialog with a "Copy Error" button
@ -422,6 +426,7 @@ func (s *appState) showErrorWithCopy(title string, err error) {
}
func (s *appState) showMainMenu() {
fmt.Printf("🎬 showMainMenu called - going to player view\n")
// Minimal entry point: go straight to the player view.
s.showPlayerView()
}
@ -464,6 +469,17 @@ func (s *appState) buildComparePane(src *videoSource, onStop func(), setSess fun
stageBG := canvas.NewRectangle(utils.MustHex("#0F1529"))
stageBG.SetMinSize(fyne.NewSize(640, 360))
videoImg := canvas.NewImageFromResource(nil)
// Populate a preview frame if available
if len(src.PreviewFrames) == 0 {
if frames, err := capturePreviewFrames(src.Path, src.Duration); err == nil && len(frames) > 0 {
src.PreviewFrames = frames
}
}
if len(src.PreviewFrames) > 0 {
s.currentFrame = src.PreviewFrames[0]
videoImg.File = s.currentFrame
videoImg.Refresh()
}
videoImg.FillMode = canvas.ImageFillContain
stage := container.NewMax(stageBG, videoImg)
@ -561,10 +577,12 @@ func (s *appState) buildComparePane(src *videoSource, onStop func(), setSess fun
// showPlayerView renders the player-focused UI with a lightweight playlist.
func (s *appState) showPlayerView() {
fmt.Printf("🎬 showPlayerView called\n")
s.stopPreview()
s.stopPlayer()
s.stopCompareSessions()
s.active = "player"
fmt.Printf("📺 s.source is nil: %v\n", s.source == nil)
// Helper to refresh the view after selection/loads.
refresh := func() {
@ -606,21 +624,20 @@ func (s *appState) showPlayerView() {
}),
)
viewMenu := fyne.NewMenu("View",
fyne.NewMenuItem("Playlist", func() {
// Will be implemented with playlist toggle
}),
)
// Simplified View menu - only essential items
viewMenu := fyne.NewMenu("View")
// Only show Compare Videos if we have multiple videos loaded
if len(s.loadedVideos) >= 2 {
viewMenu.Items = append(viewMenu.Items, fyne.NewMenuItem("Compare Videos", func() {
s.showCompareView()
}))
}
// Tools menu with essential features
toolsMenu := fyne.NewMenu("Tools")
// Keyframing mode toggle
// Keyframing mode toggle - main feature
keyframeModeItem := fyne.NewMenuItem("Frame-Accurate Mode", func() {
s.keyframingMode = !s.keyframingMode
@ -692,9 +709,12 @@ func (s *appState) showPlayerView() {
container.NewCenter(hint),
)
playerArea = container.NewMax(bg, container.NewCenter(centerStack))
playerArea := container.NewMax(bg, container.NewCenter(centerStack))
fmt.Printf("📺 Empty player view created\n")
s.setContent(playerArea)
} else {
src := s.source
fmt.Printf("🎬 Creating player view for loaded video\n")
// Image surface
stageBG := canvas.NewRectangle(utils.MustHex("#0F1529"))
@ -739,14 +759,18 @@ func (s *appState) showPlayerView() {
playlistContainer.Resize(fyne.NewSize(250, 540))
// Playlist starts hidden by default - user can toggle with menu button
var playlistVisible bool = false
playlistVisible := false
var mainContent *fyne.Container
if playlistVisible {
mainContent = container.NewBorder(nil, nil, nil, playlistContainer, container.NewPadded(stage))
} else {
mainContent = container.NewPadded(stage)
// Function to update main content layout
updateMainContent := func() {
if playlistVisible {
mainContent = container.NewBorder(nil, nil, nil, playlistContainer, container.NewPadded(stage))
} else {
mainContent = container.NewPadded(stage)
}
}
updateMainContent()
currentTime := widget.NewLabel("0:00")
totalTime := widget.NewLabel(src.DurationString())
@ -798,6 +822,10 @@ func (s *appState) showPlayerView() {
ensureSession = func() bool {
if s.playSess == nil {
s.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, 960, 540, updateProgress, videoImg)
if s.playSess == nil {
fmt.Printf("❌ ERROR: Failed to create play session\n")
return false
}
s.playSess.SetVolume(s.playerVolume)
s.playerPaused = true
}
@ -825,14 +853,18 @@ func (s *appState) showPlayerView() {
var playBtn *widget.Button
playBtn = ui.NewIconButton(ui.IconPlayArrow, "Play/Pause", func() {
fmt.Printf("🎮 Play button clicked (paused: %v)\n", s.playerPaused)
if !ensureSession() {
fmt.Printf("❌ ERROR: Failed to ensure play session\n")
return
}
if s.playerPaused {
fmt.Printf("▶️ Starting playback...\n")
s.playSess.Play()
s.playerPaused = false
playBtn.SetText(ui.IconPause)
} else {
fmt.Printf("⏸️ Pausing playback...\n")
s.playSess.Pause()
s.playerPaused = true
playBtn.SetText(ui.IconPlayArrow)
@ -848,10 +880,13 @@ func (s *appState) showPlayerView() {
var volIcon *widget.Button
volIcon = ui.NewIconButton(ui.IconVolumeUp, "Mute/Unmute", func() {
fmt.Printf("🔊 Volume button clicked (muted: %v, volume: %.1f)\n", s.playerMuted, s.playerVolume)
if !ensureSession() {
fmt.Printf("❌ ERROR: Failed to ensure play session for volume control\n")
return
}
if s.playerMuted {
fmt.Printf("🔊 Unmuting volume to %.1f\n", s.lastVolume)
target := s.lastVolume
if target <= 0 {
target = 50
@ -860,6 +895,7 @@ func (s *appState) showPlayerView() {
s.playerMuted = false
s.playSess.SetVolume(target)
} else {
fmt.Printf("🔇 Muting volume\n")
s.lastVolume = s.playerVolume
s.playerVolume = 0
s.playerMuted = true
@ -888,15 +924,10 @@ func (s *appState) showPlayerView() {
// 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()
fmt.Printf("📋 Playlist toggle: %v\n", playlistVisible)
updateMainContent()
// Refresh the entire player view to apply layout changes
s.setContent(playerArea)
})
// Progress bar - use timeline in keyframing mode, slider otherwise
@ -970,10 +1001,11 @@ func (s *appState) showPlayerView() {
}
})
// Center the playback controls
playbackControls := container.NewHBox(prevBtn, playBtn, nextBtn)
// Create basic playback controls (always centered)
basicControls := container.NewHBox(prevBtn, playBtn, nextBtn)
// Add frame navigation if keyframing mode is enabled
var playbackControls fyne.CanvasObject
if s.keyframingMode {
playbackControls = container.NewHBox(
prevBtn,
@ -984,23 +1016,25 @@ func (s *appState) showPlayerView() {
keyframeNextBtn,
nextBtn,
)
} else {
playbackControls = basicControls
}
// Create control row with centered playback controls
var controlRow *fyne.Container
// Create control row with properly centered playback controls
var controlRow fyne.CanvasObject
if s.keyframingMode {
// Show frame counter in keyframing mode
controlRow = container.NewBorder(
nil, nil,
volContainer, // Volume on left
volContainer, // Volume on left
container.NewHBox(frameCounter, playlistToggleBtn), // Frame counter and playlist toggle on right
container.NewCenter(playbackControls), // Playback controls centered
container.NewCenter(playbackControls), // Playback controls centered
)
} else {
controlRow = container.NewBorder(
nil, nil,
volContainer, // Volume on left
container.NewHBox(playlistToggleBtn), // Playlist toggle on right
volContainer, // Volume on left
container.NewHBox(playlistToggleBtn), // Playlist toggle on right
container.NewCenter(playbackControls), // Playback controls centered
)
}
@ -1073,18 +1107,17 @@ func (s *appState) showPlayerView() {
// Previously had TappableOverlay here but it blocked all button clicks
// Need to redesign so controls overlay the video without blocking interaction
playerArea = container.NewBorder(
playerArea := container.NewBorder(
nil,
container.NewVBox(container.NewPadded(progressBar), container.NewPadded(controlRow)),
nil,
nil,
mainContent,
)
fmt.Printf("🎬 Player view layout created\n")
s.setContent(playerArea)
}
mainPanel := playerArea
s.setContent(mainPanel)
}
// Legacy queue view left in place but not used in player-only mode.
@ -1178,8 +1211,10 @@ func (s *appState) showModule(id string) {
}
func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
fmt.Printf("📦 handleModuleDrop called: moduleID=%s itemCount=%d\n", moduleID, len(items))
logging.Debug(logging.CatModule, "handleModuleDrop called: moduleID=%s itemCount=%d", moduleID, len(items))
if len(items) == 0 {
fmt.Printf("❌ No items to process\n")
logging.Debug(logging.CatModule, "handleModuleDrop: no items to process")
return
}
@ -2059,7 +2094,9 @@ func runGUI() {
}
})
fmt.Printf("🚀 About to call showMainMenu\n")
state.showMainMenu()
fmt.Printf("✅ showMainMenu completed\n")
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
// Start stats bar update loop on a timer
@ -3670,15 +3707,27 @@ func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, pr
fmt.Printf("🎯 Target: %dx%d\n", targetW, targetH)
fmt.Printf("═══════════════════════════════════════════════════════\n\n")
// Validate input parameters
if fps <= 0 {
fps = 24
fmt.Printf("⚠️ Invalid FPS (%.2f), defaulting to 24\n", fps)
}
if targetW <= 0 {
targetW = 640
fmt.Printf("⚠️ Invalid target width (%d), defaulting to 640\n", targetW)
}
if targetH <= 0 {
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
fmt.Printf("⚠️ Invalid target height (%d), calculating to %d\n", targetH, targetH)
}
// Check if video file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("❌ ERROR: Video file does not exist: %s\n", path)
return nil
}
fmt.Printf("✅ Play session created successfully\n")
return &playSession{
path: path,
fps: fps,
@ -3842,10 +3891,11 @@ func (p *playSession) runVideo(offset float64) {
"-hide_banner", "-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
"-vf", fmt.Sprintf("scale=%d:%d", p.targetW, p.targetH),
"-vf", fmt.Sprintf("scale=%d:%d:flags=bilinear", p.targetW, p.targetH),
"-f", "rawvideo",
"-pix_fmt", "rgb24",
"-r", fmt.Sprintf("%.3f", p.fps),
"-vsync", "0", // Avoid frame duplication
"-",
}
fmt.Printf("🔧 FFmpeg command: ffmpeg %s\n", strings.Join(args, " "))
@ -3867,10 +3917,14 @@ func (p *playSession) runVideo(offset float64) {
if errMsg != "" {
fmt.Printf(" FFmpeg error: %s\n", errMsg)
}
// Check if ffmpeg is available
if _, pathErr := exec.LookPath("ffmpeg"); pathErr != nil {
fmt.Printf("❌ FATAL: ffmpeg not found in PATH: %v\n", pathErr)
}
logging.Debug(logging.CatFFMPEG, "video start failed: %v (%s)", err, errMsg)
return
}
fmt.Printf("✅ FFmpeg started in %.3fs\n", time.Since(startTime).Seconds())
fmt.Printf("✅ FFmpeg started (PID: %d) in %.3fs\n", cmd.Process.Pid, time.Since(startTime).Seconds())
// Pace frames to the source frame rate instead of hammering refreshes as fast as possible.
frameDur := time.Second
if p.fps > 0 {
@ -3880,7 +3934,7 @@ func (p *playSession) runVideo(offset float64) {
p.videoCmd = cmd
frameSize := p.targetW * p.targetH * 3
buf := make([]byte, frameSize)
fmt.Printf("📦 Frame buffer allocated: %.2f MB\n", float64(frameSize)/(1024*1024))
fmt.Printf("📦 Frame buffer allocated: %d bytes (%.2f MB)\n", frameSize, float64(frameSize)/(1024*1024))
go func() {
defer cmd.Process.Kill()
@ -3930,8 +3984,10 @@ func (p *playSession) runVideo(offset float64) {
fmt.Printf("🎞️ FIRST FRAME decoded in %.3fs (read: %.3fs)\n", elapsed.Seconds(), readDuration.Seconds())
}
if delay := time.Until(nextFrameAt); delay > 0 {
time.Sleep(delay)
// Improved frame pacing - use a more stable timing approach
now := time.Now()
if now.Before(nextFrameAt) {
time.Sleep(nextFrameAt.Sub(now))
}
nextFrameAt = nextFrameAt.Add(frameDur)
@ -4000,9 +4056,15 @@ func (p *playSession) runAudio(offset float64) {
return
}
if err := cmd.Start(); err != nil {
logging.Debug(logging.CatFFMPEG, "audio start failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
errMsg := strings.TrimSpace(stderr.String())
fmt.Printf("❌ ERROR: Audio FFmpeg failed to start: %v\n", err)
if errMsg != "" {
fmt.Printf(" Audio FFmpeg error: %s\n", errMsg)
}
logging.Debug(logging.CatFFMPEG, "audio start failed: %v (%s)", err, errMsg)
return
}
fmt.Printf("✅ Audio FFmpeg started (PID: %d)\n", cmd.Process.Pid)
p.audioCmd = cmd
ctx, err := getAudioContext(sampleRate, channels, bytesPerSample)
if err != nil {
@ -4205,7 +4267,9 @@ func (s *appState) importCoverImage(path string) (string, error) {
// handleDropPlayer accepts dropped files/folders anywhere and loads them into the playlist/player.
func (s *appState) handleDropPlayer(items []fyne.URI) {
fmt.Printf("📦 handleDropPlayer called with %d items\n", len(items))
if len(items) == 0 {
fmt.Printf("❌ No items in drop\n")
return
}
@ -4332,26 +4396,59 @@ func (s *appState) detectModuleTileAtPosition(pos fyne.Position) string {
}
func (s *appState) loadVideo(path string) {
fmt.Printf("\n═══════════════════════════════════════════════════════\n")
fmt.Printf("🎬 LOADING VIDEO\n")
fmt.Printf("═══════════════════════════════════════════════════════\n")
fmt.Printf("📁 Path: %s\n", path)
// Check if file exists first
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("❌ ERROR: File does not exist: %s\n", path)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showErrorWithCopy("File Not Found", fmt.Errorf("video file not found: %s", path))
}, false)
return
}
fmt.Printf("✅ File exists, starting analysis...\n")
fmt.Printf("═══════════════════════════════════════════════════════\n\n")
if s.playSess != nil {
fmt.Printf("⏹️ Stopping current playback session\n")
s.playSess.Stop()
s.playSess = nil
}
s.stopProgressLoop()
fmt.Printf("🔍 Probing video metadata...\n")
src, err := probeVideo(path)
if err != nil {
fmt.Printf("❌ ERROR: Video probe failed: %v\n", err)
logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showErrorWithCopy("Failed to Analyze Video", fmt.Errorf("failed to analyze %s: %w", filepath.Base(path), err))
}, false)
return
}
if frames, err := capturePreviewFrames(src.Path, src.Duration); err == nil {
fmt.Printf("✅ Video probe successful:\n")
fmt.Printf(" - Resolution: %dx%d\n", src.Width, src.Height)
fmt.Printf(" - Duration: %.2fs\n", src.Duration)
fmt.Printf(" - Frame Rate: %.2f fps\n", src.FrameRate)
fmt.Printf(" - Codec: %s\n", src.VideoCodec)
fmt.Printf("🖼️ Generating preview frames...\n")
frames, err := capturePreviewFrames(src.Path, src.Duration)
if err == nil {
src.PreviewFrames = frames
fmt.Printf("✅ Generated %d preview frames\n", len(frames))
} else {
fmt.Printf("⚠️ Preview generation failed: %v\n", err)
logging.Debug(logging.CatFFMPEG, "preview generation failed: %v", err)
}
// Maintain/extend loaded video list for navigation
fmt.Printf("📋 Managing video playlist...\n")
found := -1
for i, v := range s.loadedVideos {
if v.Path == src.Path {
@ -4363,15 +4460,22 @@ func (s *appState) loadVideo(path string) {
if found >= 0 {
s.loadedVideos[found] = src
s.currentIndex = found
fmt.Printf("🔄 Updated existing video at index %d\n", found)
} else if len(s.loadedVideos) > 0 {
s.loadedVideos = append(s.loadedVideos, src)
s.currentIndex = len(s.loadedVideos) - 1
fmt.Printf(" Added new video at index %d\n", s.currentIndex)
} else {
s.loadedVideos = []*videoSource{src}
s.currentIndex = 0
fmt.Printf("🎯 Created new playlist with video at index 0\n")
}
fmt.Printf("📺 Total videos in playlist: %d\n", len(s.loadedVideos))
fmt.Printf("🎯 Current video index: %d\n", s.currentIndex)
logging.Debug(logging.CatModule, "video loaded %+v", src)
fmt.Printf("🔄 Switching to player view...\n")
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.switchToVideo(s.currentIndex)
}, false)
@ -4502,7 +4606,9 @@ func (s *appState) loadVideos(paths []string) {
// switchToVideo switches to a specific video by index
func (s *appState) switchToVideo(index int) {
fmt.Printf("🔄 switchToVideo called with index %d (total: %d)\n", index, len(s.loadedVideos))
if index < 0 || index >= len(s.loadedVideos) {
fmt.Printf("❌ Invalid index %d (range: 0-%d)\n", index, len(s.loadedVideos)-1)
return
}
@ -4510,10 +4616,15 @@ func (s *appState) switchToVideo(index int) {
src := s.loadedVideos[index]
s.source = src
fmt.Printf("🎬 Switched to: %s\n", filepath.Base(src.Path))
fmt.Printf("📐 Resolution: %dx%d, Duration: %.2fs\n", src.Width, src.Height, src.Duration)
if len(src.PreviewFrames) > 0 {
s.currentFrame = src.PreviewFrames[0]
fmt.Printf("🖼️ Set preview frame: %s\n", src.PreviewFrames[0])
} else {
s.currentFrame = ""
fmt.Printf("⚠️ No preview frames available\n")
}
s.applyInverseDefaults(src)