Simplify player view and enable drop-to-play

This commit is contained in:
Stu 2025-12-04 06:04:35 -05:00
parent 5cc42c9ca0
commit d5458f7050
3 changed files with 432 additions and 386 deletions

View File

@ -61,3 +61,9 @@ func HandleCompare(files []string) {
logging.Debug(logging.CatModule, "compare handler invoked with %v", files) logging.Debug(logging.CatModule, "compare handler invoked with %v", files)
fmt.Println("compare", files) fmt.Println("compare", files)
} }
// HandlePlayer handles the player module
func HandlePlayer(files []string) {
logging.Debug(logging.CatModule, "player handler invoked with %v", files)
fmt.Println("player", files)
}

View File

@ -25,13 +25,20 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDro
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
title.TextSize = 28 title.TextSize = 28
var header fyne.CanvasObject
if onQueueClick != nil {
queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick)
header = container.New(layout.NewHBoxLayout(),
header := container.New(layout.NewHBoxLayout(),
title, title,
layout.NewSpacer(), layout.NewSpacer(),
queueTile, queueTile,
) )
} else {
header = container.New(layout.NewHBoxLayout(),
title,
layout.NewSpacer(),
)
}
var tileObjects []fyne.CanvasObject var tileObjects []fyne.CanvasObject
for _, mod := range modules { for _, mod := range modules {

489
main.go
View File

@ -60,15 +60,7 @@ var (
queueColor = utils.MustHex("#5961FF") queueColor = utils.MustHex("#5961FF")
modulesList = []Module{ modulesList = []Module{
{"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet {"player", "Player", utils.MustHex("#4CE870"), modules.HandlePlayer},
{"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue
{"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan
{"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green
{"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green
{"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow
{"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange
{"compare", "Compare", utils.MustHex("#FF44AA"), modules.HandleCompare}, // Pink
{"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red
} }
) )
@ -419,46 +411,265 @@ func (s *appState) showErrorWithCopy(title string, err error) {
} }
func (s *appState) showMainMenu() { func (s *appState) showMainMenu() {
// Minimal entry point: go straight to the player view.
s.showPlayerView()
}
// showPlayerView renders the player-focused UI with a lightweight playlist.
func (s *appState) showPlayerView() {
s.stopPreview() s.stopPreview()
s.stopPlayer() s.stopPlayer()
s.active = "" s.active = "player"
// Convert Module slice to ui.ModuleInfo slice header := widget.NewLabelWithStyle("VT Player", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
var mods []ui.ModuleInfo
for _, m := range modulesList { // Helper to refresh the view after selection/loads.
mods = append(mods, ui.ModuleInfo{ refresh := func() {
ID: m.ID, s.showPlayerView()
Label: m.Label, }
Color: m.Color,
Enabled: m.ID == "convert" || m.ID == "compare", // Convert and compare modules are functional openFile := widget.NewButton("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()
})
addFolder := widget.NewButton("Add 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()
})
clearList := widget.NewButton("Clear Playlist", func() {
s.clearVideo()
refresh()
})
clearList.Importance = widget.LowImportance
controlsBar := container.NewHBox(openFile, addFolder, clearList, layout.NewSpacer())
// 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),
layout.NewSpacer(),
container.NewCenter(loadBtn),
container.NewCenter(hint),
)
playerArea = container.NewMax(bg, container.NewCenter(centerStack))
} else {
// Playlist panel (only when we have media loaded)
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)
}
playlistPanel := container.NewBorder(
widget.NewLabelWithStyle("Playlist", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
nil, nil, nil,
playlist,
)
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
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
updateProgress := func(val float64) {
fyne.Do(func() {
updatingProgress = true
currentTime.SetText(formatClock(val))
slider.SetValue(val)
updatingProgress = false
}) })
} }
titleColor := utils.MustHex("#4CE870") var ensureSession func() bool
ensureSession = func() bool {
// Get queue stats - show completed jobs out of total if s.playSess == nil {
var queueCompleted, queueTotal int s.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, 960, 540, updateProgress, videoImg)
if s.jobQueue != nil { s.playSess.SetVolume(s.playerVolume)
_, _, completed, _ := s.jobQueue.Stats() s.playerPaused = true
queueCompleted = completed }
queueTotal = len(s.jobQueue.List()) return s.playSess != nil
} }
menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueCompleted, queueTotal) slider.OnChanged = func(val float64) {
if updatingProgress {
return
}
updateProgress(val)
if ensureSession() {
s.playSess.Seek(val)
}
}
// Update stats bar playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() {
s.updateStatsBar() if !ensureSession() {
return
}
if s.playerPaused {
s.playSess.Play()
s.playerPaused = false
} else {
s.playSess.Pause()
s.playerPaused = true
}
})
// Add stats bar at the bottom of the menu prevBtn := utils.MakeIconButton("⏮", "Previous", func() {
content := container.NewBorder( s.prevVideo()
nil, // top })
s.statsBar, // bottom nextBtn := utils.MakeIconButton("⏭", "Next", func() {
nil, // left s.nextVideo()
nil, // right })
container.NewPadded(menu), // center
var volIcon *widget.Button
volIcon = utils.MakeIconButton("🔊", "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)
}
if s.playerMuted || s.playerVolume <= 0 {
volIcon.SetText("🔇")
} else {
volIcon.SetText("🔊")
}
})
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)
}
if s.playerMuted || s.playerVolume <= 0 {
volIcon.SetText("🔇")
} else {
volIcon.SetText("🔊")
}
}
progressBar := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
controlRow := container.NewHBox(prevBtn, playBtn, nextBtn, layout.NewSpacer(), volIcon, container.NewMax(volSlider))
playerArea = container.NewBorder(
nil,
container.NewVBox(container.NewPadded(progressBar), container.NewPadded(controlRow)),
playlistPanel,
nil,
container.NewPadded(stage),
)
}
mainPanel := container.NewBorder(
container.NewVBox(header, controlsBar, widget.NewSeparator()),
nil,
nil,
nil,
playerArea,
) )
s.setContent(content) s.setContent(mainPanel)
} }
func (s *appState) showQueue() { func (s *appState) showQueue() {
@ -655,10 +866,8 @@ func (s *appState) addConvertToQueue() error {
func (s *appState) showModule(id string) { func (s *appState) showModule(id string) {
switch id { switch id {
case "convert": case "player":
s.showConvertView(nil) s.showPlayerView()
case "compare":
s.showCompareView()
default: default:
logging.Debug(logging.CatUI, "UI module %s not wired yet", id) logging.Debug(logging.CatUI, "UI module %s not wired yet", id)
} }
@ -697,70 +906,15 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
return return
} }
// If convert module and multiple files, add all to queue // Player: if multiple files, load as playlist; otherwise load single.
if moduleID == "convert" && len(videoPaths) > 1 { if len(videoPaths) > 1 {
go s.batchAddToQueue(videoPaths) go s.loadVideos(videoPaths)
return return
} }
// If compare module, load up to 2 videos into compare slots
if moduleID == "compare" {
go func() {
// Load first video
src1, err := probeVideo(videoPaths[0])
if err != nil {
logging.Debug(logging.CatModule, "failed to load first video for compare: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
}, false)
return
}
// Load second video if available
var src2 *videoSource
if len(videoPaths) >= 2 {
src2, err = probeVideo(videoPaths[1])
if err != nil {
logging.Debug(logging.CatModule, "failed to load second video for compare: %v", err)
// Continue with just first video
}
}
// Show dialog if more than 2 videos
if len(videoPaths) > 2 {
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowInformation("Compare Videos",
fmt.Sprintf("You dropped %d videos. Only the first two will be loaded for comparison.", len(videoPaths)),
s.window)
}, false)
}
// Update state and show module (with small delay to allow flash animation to be seen)
time.Sleep(350 * time.Millisecond)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.compareFile1 = src1
s.compareFile2 = src2
s.showModule(moduleID)
logging.Debug(logging.CatModule, "loaded %d video(s) for compare module", len(videoPaths))
}, false)
}()
return
}
// Single file or non-convert module: load first video and show module
path := videoPaths[0] path := videoPaths[0]
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path) logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
go s.loadVideo(path)
go func() {
logging.Debug(logging.CatModule, "loading video in goroutine")
s.loadVideo(path)
// After loading, switch to the module (with small delay to allow flash animation)
time.Sleep(350 * time.Millisecond)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
logging.Debug(logging.CatModule, "showing module %s after load", moduleID)
s.showModule(moduleID)
}, false)
}()
} }
// isVideoFile checks if a file has a video extension // isVideoFile checks if a file has a video extension
@ -1585,7 +1739,7 @@ func runGUI() {
defer state.shutdown() defer state.shutdown()
w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) { w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) {
state.handleDrop(pos, items) state.handleDropPlayer(items)
}) })
state.showMainMenu() state.showMainMenu()
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList)) logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
@ -3616,26 +3770,12 @@ func (s *appState) importCoverImage(path string) (string, error) {
return dest, nil return dest, nil
} }
func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) { // handleDropPlayer accepts dropped files/folders anywhere and loads them into the playlist/player.
func (s *appState) handleDropPlayer(items []fyne.URI) {
if len(items) == 0 { if len(items) == 0 {
return return
} }
// If on main menu, detect which module tile was dropped on
if s.active == "" {
moduleID := s.detectModuleTileAtPosition(pos)
if moduleID != "" {
logging.Debug(logging.CatUI, "drop on main menu tile=%s", moduleID)
s.handleModuleDrop(moduleID, items)
return
}
logging.Debug(logging.CatUI, "drop on main menu but not over any module tile")
return
}
// If in convert module, handle all files
if s.active == "convert" {
// Collect all video files from the dropped items
var videoPaths []string var videoPaths []string
for _, uri := range items { for _, uri := range items {
if uri.Scheme() != "file" { if uri.Scheme() != "file" {
@ -3644,9 +3784,7 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
path := uri.Path() path := uri.Path()
logging.Debug(logging.CatModule, "drop received path=%s", path) logging.Debug(logging.CatModule, "drop received path=%s", path)
// Check if it's a directory
if info, err := os.Stat(path); err == nil && info.IsDir() { if info, err := os.Stat(path); err == nil && info.IsDir() {
logging.Debug(logging.CatModule, "processing directory: %s", path)
videos := s.findVideoFiles(path) videos := s.findVideoFiles(path)
videoPaths = append(videoPaths, videos...) videoPaths = append(videoPaths, videos...)
} else if s.isVideoFile(path) { } else if s.isVideoFile(path) {
@ -3659,116 +3797,11 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
return return
} }
// If multiple videos, add all to queue
if len(videoPaths) > 1 { if len(videoPaths) > 1 {
logging.Debug(logging.CatUI, "multiple videos dropped in convert module; adding all to queue") go s.loadVideos(videoPaths)
go s.batchAddToQueue(videoPaths)
} else { } else {
// Single video: load it
logging.Debug(logging.CatUI, "single video dropped in convert module; loading: %s", videoPaths[0])
go s.loadVideo(videoPaths[0]) go s.loadVideo(videoPaths[0])
} }
return
}
// If in compare module, handle up to 2 video files
if s.active == "compare" {
// Collect all video files from the dropped items
var videoPaths []string
for _, uri := range items {
if uri.Scheme() != "file" {
continue
}
path := uri.Path()
logging.Debug(logging.CatModule, "drop received path=%s", path)
// Only accept video files (not directories)
if s.isVideoFile(path) {
videoPaths = append(videoPaths, path)
}
}
if len(videoPaths) == 0 {
logging.Debug(logging.CatUI, "no valid video files in dropped items")
dialog.ShowInformation("Compare Videos", "No video files found in dropped items.", s.window)
return
}
// Show message if more than 2 videos dropped
if len(videoPaths) > 2 {
dialog.ShowInformation("Compare Videos",
fmt.Sprintf("You dropped %d videos. Only the first two will be loaded for comparison.", len(videoPaths)),
s.window)
}
// Load videos sequentially to avoid race conditions
go func() {
if len(videoPaths) == 1 {
// Single video dropped - fill first empty slot
src, err := probeVideo(videoPaths[0])
if err != nil {
logging.Debug(logging.CatModule, "failed to load video: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
}, false)
return
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
// Fill first empty slot
if s.compareFile1 == nil {
s.compareFile1 = src
logging.Debug(logging.CatModule, "loaded video into slot 1")
} else if s.compareFile2 == nil {
s.compareFile2 = src
logging.Debug(logging.CatModule, "loaded video into slot 2")
} else {
// Both slots full - ask which to replace
dialog.ShowInformation("Both Slots Full",
"Both comparison slots are full. Use the Clear button to empty a slot first.",
s.window)
return
}
s.showCompareView()
}, false)
} else {
// Multiple videos dropped - load into both slots
src1, err := probeVideo(videoPaths[0])
if err != nil {
logging.Debug(logging.CatModule, "failed to load first video: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("failed to load video 1: %w", err), s.window)
}, false)
return
}
var src2 *videoSource
if len(videoPaths) >= 2 {
src2, err = probeVideo(videoPaths[1])
if err != nil {
logging.Debug(logging.CatModule, "failed to load second video: %v", err)
// Continue with just first video
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("failed to load video 2: %w", err), s.window)
}, false)
}
}
// Update both slots and refresh view once
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.compareFile1 = src1
s.compareFile2 = src2
s.showCompareView()
logging.Debug(logging.CatModule, "loaded %d video(s) into both slots", len(videoPaths))
}, false)
}
}()
return
}
// Other modules don't handle file drops yet
logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active)
} }
// detectModuleTileAtPosition calculates which module tile is at the given position // detectModuleTileAtPosition calculates which module tile is at the given position
@ -3897,7 +3930,7 @@ func (s *appState) loadVideo(path string) {
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.showPlayerView()
}, false) }, false)
} }
@ -3915,7 +3948,7 @@ func (s *appState) clearVideo() {
s.convert.AspectHandling = "Auto" s.convert.AspectHandling = "Auto"
s.convert.OutputAspect = "Source" s.convert.OutputAspect = "Source"
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showConvertView(nil) s.showPlayerView()
}, false) }, false)
} }
@ -4056,7 +4089,7 @@ func (s *appState) switchToVideo(index int) {
s.playerPaused = true s.playerPaused = true
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showConvertView(src) s.showPlayerView()
}, false) }, false)
} }