diff --git a/internal/modules/handlers.go b/internal/modules/handlers.go index 79f547b..ec7f5e1 100644 --- a/internal/modules/handlers.go +++ b/internal/modules/handlers.go @@ -61,3 +61,9 @@ func HandleCompare(files []string) { logging.Debug(logging.CatModule, "compare handler invoked with %v", 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) +} diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index 860adcb..2f1bd39 100644 --- a/internal/ui/mainmenu.go +++ b/internal/ui/mainmenu.go @@ -25,13 +25,20 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDro title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} title.TextSize = 28 - queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) - - header := container.New(layout.NewHBoxLayout(), - title, - layout.NewSpacer(), - queueTile, - ) + var header fyne.CanvasObject + if onQueueClick != nil { + queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) + header = container.New(layout.NewHBoxLayout(), + title, + layout.NewSpacer(), + queueTile, + ) + } else { + header = container.New(layout.NewHBoxLayout(), + title, + layout.NewSpacer(), + ) + } var tileObjects []fyne.CanvasObject for _, mod := range modules { diff --git a/main.go b/main.go index a089f69..72450fc 100644 --- a/main.go +++ b/main.go @@ -60,15 +60,7 @@ var ( queueColor = utils.MustHex("#5961FF") modulesList = []Module{ - {"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet - {"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue - {"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan - {"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green - {"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green - {"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow - {"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange - {"compare", "Compare", utils.MustHex("#FF44AA"), modules.HandleCompare}, // Pink - {"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red + {"player", "Player", utils.MustHex("#4CE870"), modules.HandlePlayer}, } ) @@ -117,33 +109,33 @@ type convertConfig struct { 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) + 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 + 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 @@ -419,46 +411,265 @@ func (s *appState) showErrorWithCopy(title string, err error) { } 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.stopPlayer() - s.active = "" + s.active = "player" - // Convert Module slice to ui.ModuleInfo slice - var mods []ui.ModuleInfo - for _, m := range modulesList { - mods = append(mods, ui.ModuleInfo{ - ID: m.ID, - Label: m.Label, - Color: m.Color, - Enabled: m.ID == "convert" || m.ID == "compare", // Convert and compare modules are functional + header := widget.NewLabelWithStyle("VT Player", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + + // Helper to refresh the view after selection/loads. + refresh := func() { + s.showPlayerView() + } + + 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 + }) + } + + 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) + } + } + + playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() { + if !ensureSession() { + return + } + if s.playerPaused { + s.playSess.Play() + s.playerPaused = false + } else { + s.playSess.Pause() + s.playerPaused = true + } + }) + + prevBtn := utils.MakeIconButton("⏮", "Previous", func() { + s.prevVideo() + }) + nextBtn := utils.MakeIconButton("⏭", "Next", func() { + s.nextVideo() + }) + + 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), + ) } - titleColor := utils.MustHex("#4CE870") - - // Get queue stats - show completed jobs out of total - var queueCompleted, queueTotal int - if s.jobQueue != nil { - _, _, completed, _ := s.jobQueue.Stats() - queueCompleted = completed - queueTotal = len(s.jobQueue.List()) - } - - menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, titleColor, queueColor, textColor, queueCompleted, queueTotal) - - // Update stats bar - s.updateStatsBar() - - // Add stats bar at the bottom of the menu - content := container.NewBorder( - nil, // top - s.statsBar, // bottom - nil, // left - nil, // right - container.NewPadded(menu), // center + mainPanel := container.NewBorder( + container.NewVBox(header, controlsBar, widget.NewSeparator()), + nil, + nil, + nil, + playerArea, ) - s.setContent(content) + s.setContent(mainPanel) } func (s *appState) showQueue() { @@ -597,45 +808,45 @@ func (s *appState) addConvertToQueue() error { // 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, + "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{ @@ -655,10 +866,8 @@ func (s *appState) addConvertToQueue() error { func (s *appState) showModule(id string) { switch id { - case "convert": - s.showConvertView(nil) - case "compare": - s.showCompareView() + case "player": + s.showPlayerView() default: 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 } - // If convert module and multiple files, add all to queue - if moduleID == "convert" && len(videoPaths) > 1 { - go s.batchAddToQueue(videoPaths) + // Player: if multiple files, load as playlist; otherwise load single. + if len(videoPaths) > 1 { + go s.loadVideos(videoPaths) 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] logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, 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) - }() + go s.loadVideo(path) } // isVideoFile checks if a file has a video extension @@ -827,39 +981,39 @@ func (s *appState) batchAddToQueue(paths []string) { 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, + "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{ @@ -1398,10 +1552,10 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre 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")) + (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") @@ -1525,28 +1679,28 @@ func runGUI() { 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, + 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, + AudioCodec: "AAC", + AudioBitrate: "192k", + AudioChannels: "Source", + AudioSampleRate: "Source", + NormalizeAudio: false, // Other defaults InverseTelecine: true, @@ -1585,7 +1739,7 @@ func runGUI() { defer state.shutdown() w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) { - state.handleDrop(pos, items) + state.handleDropPlayer(items) }) state.showMainMenu() logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList)) @@ -3616,159 +3770,38 @@ func (s *appState) importCoverImage(path string) (string, error) { 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 { 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 + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue } - logging.Debug(logging.CatUI, "drop on main menu but not over any module tile") + path := uri.Path() + logging.Debug(logging.CatModule, "drop received path=%s", path) + + if info, err := os.Stat(path); err == nil && info.IsDir() { + videos := s.findVideoFiles(path) + videoPaths = append(videoPaths, videos...) + } else if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") return } - // If in convert module, handle all files - if s.active == "convert" { - // 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) - - // 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) - } - } - - if len(videoPaths) == 0 { - logging.Debug(logging.CatUI, "no valid video files in dropped items") - return - } - - // If multiple videos, add all to queue - if len(videoPaths) > 1 { - logging.Debug(logging.CatUI, "multiple videos dropped in convert module; adding all to queue") - go s.batchAddToQueue(videoPaths) - } else { - // Single video: load it - logging.Debug(logging.CatUI, "single video dropped in convert module; loading: %s", videoPaths[0]) - go s.loadVideo(videoPaths[0]) - } - return + if len(videoPaths) > 1 { + go s.loadVideos(videoPaths) + } else { + go s.loadVideo(videoPaths[0]) } - - // 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 @@ -3897,7 +3930,7 @@ func (s *appState) loadVideo(path string) { logging.Debug(logging.CatModule, "video loaded %+v", src) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - s.showConvertView(src) + s.showPlayerView() }, false) } @@ -3915,7 +3948,7 @@ func (s *appState) clearVideo() { s.convert.AspectHandling = "Auto" s.convert.OutputAspect = "Source" fyne.CurrentApp().Driver().DoFromGoroutine(func() { - s.showConvertView(nil) + s.showPlayerView() }, false) } @@ -4056,7 +4089,7 @@ func (s *appState) switchToVideo(index int) { s.playerPaused = true fyne.CurrentApp().Driver().DoFromGoroutine(func() { - s.showConvertView(src) + s.showPlayerView() }, false) } @@ -4685,10 +4718,10 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But 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")) + (strings.Contains(stderrOutput, "nvenc") || + strings.Contains(stderrOutput, "qsv") || + strings.Contains(stderrOutput, "vaapi") || + strings.Contains(stderrOutput, "videotoolbox")) if isHardwareFailure && s.convert.HardwareAccel != "none" && s.convert.HardwareAccel != "" { errorMsg = 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", s.convert.HardwareAccel, stderrOutput) @@ -5076,8 +5109,8 @@ type videoSource struct { Duration float64 VideoCodec string AudioCodec string - Bitrate int // Video bitrate in bits per second - AudioBitrate int // Audio bitrate in bits per second + Bitrate int // Video bitrate in bits per second + AudioBitrate int // Audio bitrate in bits per second FrameRate float64 PixelFormat string AudioRate int