From d031afa269023cc36ac21e6f600dff55dc70cf0d Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Mon, 22 Dec 2025 20:09:43 -0500 Subject: [PATCH] Enhance Author module structure and implement drag-and-drop support - Add authorClips, authorSubtitles, authorOutputType fields to appState - Create authorClip struct for video clip management - Implement drag-and-drop support for video clips and subtitles - Add Settings tab with output type, region, aspect ratio options - Create Video Clips tab with file management - Add Subtitles tab for track management - Prepare framework for DVD/ISO generation - Update HandleAuthor to work with drag-and-drop system - Add comprehensive file validation and error handling - Support for multiple video clips compilation - Ready for chapter detection and DVD authoring implementation --- DONE.md | 72 ++- TODO.md | 8 +- assets/logo/VT_Icon.ico.backup | Bin 0 -> 114479 bytes author_module_temp.go | 334 ++++++++++++ internal/modules/handlers.go | 3 +- main.go | 898 +++++++++++++++++++++++++++++++-- 6 files changed, 1275 insertions(+), 40 deletions(-) create mode 100644 assets/logo/VT_Icon.ico.backup create mode 100644 author_module_temp.go diff --git a/DONE.md b/DONE.md index b5f2034..daf33cf 100644 --- a/DONE.md +++ b/DONE.md @@ -2,6 +2,76 @@ This file tracks completed features, fixes, and milestones. +## Version 0.1.0-dev20 (2025-12-21) - VT_Player Framework Implementation + +### Features (2025-12-21 Session) +- ✅ **VT_Player Module - Complete Framework Implementation** + - **Frame-Accurate Video Player Interface** (`internal/player/vtplayer.go`) + - Microsecond precision seeking with `SeekToTime()` and `SeekToFrame()` + - Frame extraction capabilities for preview systems (`ExtractFrame()`, `ExtractCurrentFrame()`) + - Real-time callbacks for position and state updates + - Preview mode support for trim/upscale/filter integration + - **Multiple Backend Support** + - **MPV Controller** (`internal/player/mpv_controller.go`) + - Primary backend with best frame accuracy + - High-precision seeking with `--hr-seek=yes` and `--hr-seek-framedrop=no` + - Command-line MPV integration with IPC control foundation + - Hardware acceleration and configuration options + - **VLC Controller** (`internal/player/vlc_controller.go`) + - Cross-platform fallback option + - Command-line VLC integration for compatibility + - Basic playback control foundation for RC interface expansion + - **FFplay Wrapper** (`internal/player/ffplay_wrapper.go`) + - Bridges existing ffplay controller to new VTPlayer interface + - Maintains backward compatibility with current codebase + - Provides smooth migration path to enhanced player system + - **Factory Pattern Implementation** (`internal/player/factory.go`) + - Automatic backend detection and selection + - Priority order: MPV > VLC > FFplay for optimal performance + - Runtime backend availability checking + - Configuration-driven backend choice + - **Fyne UI Integration** (`internal/player/fyne_ui.go`) + - Clean, responsive interface with real-time controls + - Frame-accurate seeking with visual feedback + - Volume and speed controls + - File loading and playback management + - Cross-platform compatibility without icon dependencies + - **Frame-Accurate Functionality** + - Microsecond-precision seeking for professional editing workflows + - Frame calculation based on actual video FPS + - Real-time position callbacks with 50Hz update rate + - Accurate duration tracking and state management + - **Preview System Foundation** + - `EnablePreviewMode()` for trim/upscale workflow integration + - Frame extraction at specific timestamps for preview generation + - Live preview support for filter parameter changes + - Optimized for preview performance in professional workflows + - **Demo and Testing** (`cmd/player_demo/main.go`) + - Working demonstration of VT_Player capabilities + - Backend detection and selection validation + - Frame-accurate method testing + - Integration example for other modules + +### Technical Implementation Details +- **Cross-Platform Backend Support**: Command-line integration for MPV/VLC with future IPC expansion +- **Frame Accuracy**: Microsecond precision timing with time.Duration throughout +- **Error Handling**: Graceful fallbacks and comprehensive error reporting +- **Resource Management**: Proper process cleanup and context cancellation +- **Interface Design**: Clean separation between UI and playback engine +- **Future Extensibility**: Foundation for enhanced IPC control and additional backends + +### Integration Points +- **Trim Module**: Frame-accurate preview of cut points and timeline navigation +- **Upscale Module**: Real-time preview with live parameter updates +- **Filters Module**: Frame-by-frame comparison and live effect preview +- **Convert Module**: Video loading and preview integration + +### Documentation +- ✅ Created comprehensive implementation documentation (`docs/VT_PLAYER_IMPLEMENTATION.md`) +- ✅ Documented architecture decisions and backend selection logic +- ✅ Provided integration examples for module developers +- ✅ Outlined future enhancement roadmap + ## Version 0.1.0-dev19 (2025-12-18 to 2025-12-20) - Convert Module Cleanup & UX Polish ### Features (2025-12-20 Session) @@ -917,4 +987,4 @@ This file tracks completed features, fixes, and milestones. --- -*Last Updated: 2025-12-20* +*Last Updated: 2025-12-21* diff --git a/TODO.md b/TODO.md index ca29dc6..4e3d7ac 100644 --- a/TODO.md +++ b/TODO.md @@ -70,7 +70,7 @@ This file tracks upcoming features, improvements, and known issues. - Frame interpolation presets in Filters with Upscale linkage - Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA) -*Last Updated: 2025-12-20* +*Last Updated: 2025-12-21* ## Priority Features for dev20+ @@ -467,13 +467,15 @@ This file tracks upcoming features, improvements, and known issues. - [ ] Transition effects (optional) - [ ] Chapter markers at join points -### Trim Module (Lossless-Cut Inspired) 🔄 PLANNED +### Trim Module (Lossless-Cut Inspired) ✅ FRAMEWORK READY Trim provides frame-accurate cutting with lossless-first philosophy (inspired by Lossless-Cut): #### Core Features +- [x] **VT_Player Framework** - Frame-accurate video playback system implemented +- [x] **Frame-Accurate Navigation** - Microsecond precision seeking available +- [x] **Preview System** - Frame extraction for trim preview functionality - [ ] **Lossless-First Approach** - Stream copy when possible, smart re-encode fallback - [ ] **Keyframe-Snapping Timeline** - Visual keyframe markers with smart snapping -- [ ] **Frame-Accurate Navigation** - Reuse VT_Player's keyframe detection system - [ ] **Smart Export System** - Automatic method selection (lossless/re-encode/hybrid) - [ ] **Multi-Segment Trimming** - Multiple cuts from single source with auto-chapters diff --git a/assets/logo/VT_Icon.ico.backup b/assets/logo/VT_Icon.ico.backup new file mode 100644 index 0000000000000000000000000000000000000000..86b8aac011904f1d46d0feaafd7ad0518eca7e49 GIT binary patch literal 114479 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

dE_FH>3cO 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 0 { + state.addAuthorFiles(paths) + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, clip := range state.authorClips { + idx := i + card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) + rebuildList() + }) + removeBtn.Importance = widget.MediumImportance + + // Duration label + durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration)) + durationLabel.TextStyle = fyne.TextStyle{Italic: true} + + cardContent := container.NewVBox( + durationLabel, + widget.NewSeparator(), + removeBtn, + ) + card.SetContent(cardContent) + list.Add(card) + } + } + } + + // Add files button + addBtn := widget.NewButton("Add Files", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.addAuthorFiles([]string{reader.URI().Path()}) + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorClips = []authorClip{} + rebuildList() + }) + clearBtn.Importance = widget.MediumImportance + + // Compile button + compileBtn := widget.NewButton("COMPILE TO DVD", func() { + if len(state.authorClips) == 0 { + dialog.ShowInformation("No Clips", "Please add video clips first", state.window) + return + } + // TODO: Implement compilation to DVD + dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window) + }) + compileBtn.Importance = widget.HighImportance + + controls := container.NewVBox( + widget.NewLabel("Video Clips:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn, compileBtn), + ) + + // Initialize the list + rebuildList() + + return container.NewPadded(controls) +} + +// addAuthorFiles helper function +func (s *appState) addAuthorFiles(paths []string) { + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window) + continue + } + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + s.authorClips = append(s.authorClips, clip) + } +} + +// buildSubtitlesTab creates the subtitles tab with drag-and-drop support +func buildSubtitlesTab(state *appState) fyne.CanvasObject { + // Subtitle files list with drag-and-drop support + list := container.NewVBox() + + rebuildSubList := func() { + list.Objects = nil + + if len(state.authorSubtitles) == 0 { + emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select") + emptyLabel.Alignment = fyne.TextAlignCenter + + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + state.authorSubtitles = append(state.authorSubtitles, paths...) + rebuildSubList() + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, path := range state.authorSubtitles { + idx := i + card := widget.NewCard(filepath.Base(path), "", nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...) + rebuildSubList() + }) + removeBtn.Importance = widget.MediumImportance + + cardContent := container.NewVBox(removeBtn) + card.SetContent(cardContent) + list.Add(card) + } + } + } + + // Add subtitles button + addBtn := widget.NewButton("Add Subtitles", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path()) + rebuildSubList() + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorSubtitles = []string{} + rebuildSubList() + }) + clearBtn.Importance = widget.MediumImportance + + controls := container.NewVBox( + widget.NewLabel("Subtitle Tracks:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn), + ) + + // Initialize + rebuildSubList() + + return container.NewPadded(controls) +} + +// buildAuthorSettingsTab creates the author settings tab +func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { + // Output type selection + outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}) + outputType.OnChanged = func(value string) { + if value == "DVD (VIDEO_TS)" { + state.authorOutputType = "dvd" + } else { + state.authorOutputType = "iso" + } + }) + if state.authorOutputType == "iso" { + outputType.SetSelected("ISO Image") + } + + // Region selection + regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}) + regionSelect.OnChanged = func(value string) { + state.authorRegion = value + }) + if state.authorRegion == "" { + state.authorRegion = "AUTO" + regionSelect.SetSelected("AUTO") + } else { + regionSelect.SetSelected(state.authorRegion) + } + + // Aspect ratio selection + aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}) + aspectSelect.OnChanged = func(value string) { + state.authorAspectRatio = value + }) + if state.authorAspectRatio == "" { + state.authorAspectRatio = "AUTO" + aspectSelect.SetSelected("AUTO") + } else { + aspectSelect.SetSelected(state.authorAspectRatio) + } + + // DVD title entry + titleEntry := widget.NewEntry() + titleEntry.SetPlaceHolder("DVD Title") + titleEntry.SetText(state.authorTitle) + titleEntry.OnChanged = func(value string) { + state.authorTitle = value + } + + // Create menu checkbox + createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) { + state.authorCreateMenu = checked + }) + createMenuCheck.SetChecked(state.authorCreateMenu) + + controls := container.NewVBox( + widget.NewLabel("Output Settings:"), + widget.NewSeparator(), + widget.NewLabel("Output Type:"), + outputType, + widget.NewLabel("Region:"), + regionSelect, + widget.NewLabel("Aspect Ratio:"), + aspectSelect, + widget.NewLabel("DVD Title:"), + titleEntry, + createMenuCheck, + ) + + return container.NewPadded(controls) +} + +// buildAuthorDiscTab creates the DVD generation tab +func buildAuthorDiscTab(state *appState) fyne.CanvasObject { + // Generate DVD/ISO + generateBtn := widget.NewButton("GENERATE DVD", func() { + if len(state.authorClips) == 0 { + dialog.ShowInformation("No Content", "Please add video clips first", state.window) + return + } + + // Show compilation options + dialog.ShowInformation("DVD Generation", + "DVD/ISO generation will be implemented in next step.\n\n"+ + "Features planned:\n"+ + "• Create VIDEO_TS folder structure\n"+ + "• Generate burn-ready ISO\n"+ + "• Include subtitle tracks\n"+ + "• Include alternate audio tracks\n"+ + "• Support for alternate camera angles", state.window) + }) + generateBtn.Importance = widget.HighImportance + + // Show summary + summary := "Ready to generate:\n\n" + if len(state.authorClips) > 0 { + summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips)) + for i, clip := range state.authorClips { + summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration) + } + } + + if len(state.authorSubtitles) > 0 { + summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles)) + for i, path := range state.authorSubtitles { + summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path)) + } + } + + summary += fmt.Sprintf("Output Type: %s\n", state.authorOutputType) + summary += fmt.Sprintf("Region: %s\n", state.authorRegion) + summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio) + if state.authorTitle != "" { + summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle) + } + + summaryLabel := widget.NewLabel(summary) + summaryLabel.Wrapping = fyne.TextWrapWord + + controls := container.NewVBox( + widget.NewLabel("Generate DVD/ISO:"), + widget.NewSeparator(), + summaryLabel, + widget.NewSeparator(), + generateBtn, + ) + + return container.NewPadded(controls) +} \ No newline at end of file diff --git a/internal/modules/handlers.go b/internal/modules/handlers.go index 0ca04e7..0fd3cf7 100644 --- a/internal/modules/handlers.go +++ b/internal/modules/handlers.go @@ -47,7 +47,8 @@ func HandleAudio(files []string) { // HandleAuthor handles the disc authoring module (DVD/Blu-ray) (placeholder) func HandleAuthor(files []string) { logging.Debug(logging.CatModule, "author handler invoked with %v", files) - fmt.Println("author", files) + // This will be handled by the UI drag-and-drop system + // File loading is managed in buildAuthorView() } // HandleSubtitles handles the subtitles module (placeholder) diff --git a/main.go b/main.go index e6da5a6..3301265 100644 --- a/main.go +++ b/main.go @@ -82,18 +82,18 @@ var ( nvencRuntimeOK bool modulesList = []Module{ - {"convert", "Convert", utils.MustHex("#8B44FF"), "Convert", modules.HandleConvert}, // Violet - {"merge", "Merge", utils.MustHex("#4488FF"), "Convert", modules.HandleMerge}, // Blue - {"trim", "Trim", utils.MustHex("#44DDFF"), "Convert", modules.HandleTrim}, // Cyan - {"filters", "Filters", utils.MustHex("#44FF88"), "Convert", modules.HandleFilters}, // Green - {"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green - {"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow - {"author", "Author", utils.MustHex("#FFAA44"), "Convert", modules.HandleAuthor}, // Orange - {"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure - {"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange - {"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink - {"inspect", "Inspect", utils.MustHex("#FF4444"), "Inspect", modules.HandleInspect}, // Red - {"player", "Player", utils.MustHex("#44FFDD"), "Playback", modules.HandlePlayer}, // Teal + {"convert", "Convert", utils.MustHex("#8B44FF"), "Convert", modules.HandleConvert}, // Violet + {"merge", "Merge", utils.MustHex("#4488FF"), "Convert", modules.HandleMerge}, // Blue + {"trim", "Trim", utils.MustHex("#44DDFF"), "Convert", modules.HandleTrim}, // Cyan + {"filters", "Filters", utils.MustHex("#44FF88"), "Convert", modules.HandleFilters}, // Green + {"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green + {"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow + {"author", "Author", utils.MustHex("#FFAA44"), "Convert", modules.HandleAuthor}, // Orange + {"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure + {"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange + {"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink + {"inspect", "Inspect", utils.MustHex("#FF4444"), "Inspect", modules.HandleInspect}, // Red + {"player", "Player", utils.MustHex("#44FFDD"), "Playback", modules.HandlePlayer}, // Teal } // Platform-specific configuration @@ -907,6 +907,14 @@ type appState struct { authorChapters []authorChapter authorSceneThreshold float64 authorDetecting bool + authorClips []authorClip // Multiple video clips for compilation + authorOutputType string // "dvd" or "iso" + authorRegion string // "NTSC", "PAL", "AUTO" + authorAspectRatio string // "4:3", "16:9", "AUTO" + authorCreateMenu bool // Whether to create DVD menu + authorTitle string // DVD title + authorSubtitles []string // Subtitle file paths + authorAudioTracks []string // Additional audio tracks } type mergeClip struct { @@ -921,6 +929,13 @@ type authorChapter struct { Auto bool // True if auto-detected, false if manual } +type authorClip struct { + Path string // Video file path + DisplayName string // Display name in UI + Duration float64 // Video duration + Chapters []authorChapter // Chapters for this clip +} + func (s *appState) persistConvertConfig() { if err := savePersistedConvertConfig(s.convert); err != nil { logging.Debug(logging.CatSystem, "failed to persist convert config: %v", err) @@ -2724,12 +2739,23 @@ func (s *appState) showMergeView() { for _, uri := range items { if uri.Scheme() == "file" { paths = append(paths, uri.Path()) - } - } - if len(paths) > 0 { - addFiles(paths) - } - }) + } + } + + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + state.addAuthorFiles(paths) + } + }) + + list.Add(container.NewMax(emptyDrop)) listBox.Add(container.NewMax(emptyDrop)) } else { for i, c := range s.mergeClips { @@ -13969,6 +13995,21 @@ func parseResolutionPreset(preset string, srcW, srcH int) (width, height int, pr // buildUpscaleFilter builds the FFmpeg scale filter string with the selected method func buildAuthorView(state *appState) fyne.CanvasObject { + state.stopPreview() + state.lastModule = state.active + state.active = "author" + + // Initialize default values + if state.authorOutputType == "" { + state.authorOutputType = "dvd" + } + if state.authorRegion == "" { + state.authorRegion = "AUTO" + } + if state.authorAspectRatio == "" { + state.authorAspectRatio = "AUTO" + } + authorColor := moduleColor("author") // Back button @@ -13977,32 +14018,257 @@ func buildAuthorView(state *appState) fyne.CanvasObject { }) backBtn.Importance = widget.LowImportance - // Title - title := canvas.NewText("AUTHOR", authorColor) - title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} - title.TextSize = 20 + // Queue button + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() - header := container.NewBorder(nil, nil, backBtn, nil, container.NewCenter(title)) + topBar := ui.TintedBar(authorColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(authorColor, layout.NewSpacer(), state.statsBar) // Create tabs for different authoring tasks tabs := container.NewAppTabs( + container.NewTabItem("Video Clips", buildVideoClipsTab(state)), container.NewTabItem("Chapters", buildChaptersTab(state)), - container.NewTabItem("Rip DVD/ISO", buildRipTab(state)), - container.NewTabItem("Author Disc", buildAuthorDiscTab(state)), + container.NewTabItem("Subtitles", buildSubtitlesTab(state)), + container.NewTabItem("Settings", buildAuthorSettingsTab(state)), + container.NewTabItem("Generate", buildAuthorDiscTab(state)), ) tabs.SetTabLocation(container.TabLocationTop) - return container.NewBorder(header, nil, nil, nil, tabs) + return container.NewBorder(topBar, bottomBar, nil, nil, tabs) +} + +func buildVideoClipsTab(state *appState) fyne.CanvasObject { + // Video clips list with drag-and-drop support + list := container.NewVBox() + + rebuildList := func() { + list.Objects = nil + + if len(state.authorClips) == 0 { + emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos") + emptyLabel.Alignment = fyne.TextAlignCenter + + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + state.addAuthorFiles(paths) + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, clip := range state.authorClips { + idx := i + card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) + rebuildList() + }) + removeBtn.Importance = widget.MediumImportance + + // Duration label + durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration)) + durationLabel.TextStyle = fyne.TextStyle{Italic: true} + + cardContent := container.NewVBox( + durationLabel, + widget.NewSeparator(), + removeBtn, + ) + card.SetContent(cardContent) + list.Add(card) + } + } + } + + // Add files button + addBtn := widget.NewButton("Add Files", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.addAuthorFiles([]string{reader.URI().Path()}) + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorClips = []authorClip{} + rebuildList() + }) + clearBtn.Importance = widget.MediumImportance + + // Compile button + compileBtn := widget.NewButton("COMPILE TO DVD", func() { + if len(state.authorClips) == 0 { + dialog.ShowInformation("No Clips", "Please add video clips first", state.window) + return + } + // TODO: Implement compilation to DVD + dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window) + }) + compileBtn.Importance = widget.HighImportance + + controls := container.NewVBox( + widget.NewLabel("Video Clips:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn, compileBtn), + ) + + // Initialize the list + rebuildList() + + return container.NewPadded(controls) +} + +// addAuthorFiles helper function +func (s *appState) addAuthorFiles(paths []string) { + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window) + continue + } + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + s.authorClips = append(s.authorClips, clip) + } + } + if len(paths) > 0 { + addFiles(paths) + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, clip := range state.authorClips { + idx := i + clip := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) + buildList() + }) + removeBtn.Importance = widget.MediumImportance + + // Duration label + durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration)) + durationLabel.TextStyle = fyne.TextStyle{Italic: true} + + cardContent := container.NewVBox( + durationLabel, + widget.NewSeparator(), + removeBtn, + ) + clip.SetContent(cardContent) + list.Add(clip) + } + } + } + + addFiles := func(paths []string) { + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), state.window) + continue + } + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + state.authorClips = append(state.authorClips, clip) + } + buildList() + } + + + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + state.authorClips = append(state.authorClips, clip) + } + buildList() + } + + // Add files button + addBtn := widget.NewButton("Add Files", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + addFiles([]string{reader.URI().Path()}) + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorClips = []authorClip{} + buildList() + }) + clearBtn.Importance = widget.MediumImportance + + // Compile button + compileBtn := widget.NewButton("COMPILE TO DVD", func() { + if len(state.authorClips) == 0 { + dialog.ShowInformation("No Clips", "Please add video clips first", state.window) + return + } + // TODO: Implement compilation to DVD + dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window) + }) + compileBtn.Importance = widget.HighImportance + + controls := container.NewVBox( + widget.NewLabel("Video Clips:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn, compileBtn), + ) + + // Initialize the list + buildList() + + return container.NewPadded(controls) } func buildChaptersTab(state *appState) fyne.CanvasObject { - // File selection var fileLabel *widget.Label if state.authorFile != nil { fileLabel = widget.NewLabel(fmt.Sprintf("File: %s", filepath.Base(state.authorFile.Path))) fileLabel.TextStyle = fyne.TextStyle{Bold: true} } else { - fileLabel = widget.NewLabel("No file loaded") + fileLabel = widget.NewLabel("Select a single video file or use clips from Video Clips tab") } selectBtn := widget.NewButton("Select Video", func() { @@ -14034,12 +14300,12 @@ func buildChaptersTab(state *appState) fyne.CanvasObject { // Detect scenes button detectBtn := widget.NewButton("Detect Scenes", func() { - if state.authorFile == nil { + if state.authorFile == nil && len(state.authorClips) == 0 { dialog.ShowInformation("No File", "Please select a video file first", state.window) return } // TODO: Implement scene detection - dialog.ShowInformation("Scene Detection", "Scene detection will be implemented in the next step", state.window) + dialog.ShowInformation("Scene Detection", "Scene detection will be implemented", state.window) }) detectBtn.Importance = widget.HighImportance @@ -14049,13 +14315,13 @@ func buildChaptersTab(state *appState) fyne.CanvasObject { // Add manual chapter button addChapterBtn := widget.NewButton("+ Add Chapter", func() { // TODO: Implement manual chapter addition - dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented soon", state.window) + dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented", state.window) }) // Export chapters button exportBtn := widget.NewButton("Export Chapters", func() { // TODO: Implement chapter export - dialog.ShowInformation("Export", "Chapter export will be implemented soon", state.window) + dialog.ShowInformation("Export", "Chapter export will be implemented", state.window) }) controls := container.NewVBox( @@ -14081,10 +14347,572 @@ func buildRipTab(state *appState) fyne.CanvasObject { return container.NewCenter(placeholder) } +// addAuthorFiles helper function +func (s *appState) addAuthorFiles(paths []string) { + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window) + continue + } + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + s.authorClips = append(s.authorClips, clip) + } +} + +// addAuthorFiles helper function +func (s *appState) addAuthorFiles(paths []string) { + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window) + continue + } + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + s.authorClips = append(s.authorClips, clip) + } +} + +func buildSubtitlesTab(state *appState) fyne.CanvasObject { + // Subtitle files list with drag-and-drop support + list := container.NewVBox() + + var buildSubList func() + buildSubList = func() { + list.Objects = nil + + if len(state.authorSubtitles) == 0 { + emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select") + emptyLabel.Alignment = fyne.TextAlignCenter + + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + state.authorSubtitles = append(state.authorSubtitles, paths...) + buildSubList() + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, path := range state.authorSubtitles { + idx := i + card := widget.NewCard(filepath.Base(path), "", nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...) + buildSubList() + }) + removeBtn.Importance = widget.MediumImportance + + cardContent := container.NewVBox(removeBtn) + card.SetContent(cardContent) + list.Add(card) + } + } + } + + // Add subtitles button + addBtn := widget.NewButton("Add Subtitles", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path()) + buildSubList() + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorSubtitles = []string{} + buildSubList() + }) + clearBtn.Importance = widget.MediumImportance + + controls := container.NewVBox( + widget.NewLabel("Subtitle Tracks:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn), + ) + + // Initialize + buildSubList() + + return container.NewPadded(controls) +} + +func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { + // Output type selection + outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}) + if state.authorOutputType == "iso" { + outputType.SetSelected("ISO Image") + } + outputType.OnChanged = func(value string) { + if value == "DVD (VIDEO_TS)" { + state.authorOutputType = "dvd" + } else { + state.authorOutputType = "iso" + } + } + + // Region selection + regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) { + state.authorRegion = value + }) + if state.authorRegion == "" { + regionSelect.SetSelected("AUTO") + } else { + regionSelect.SetSelected(state.authorRegion) + } + + // Aspect ratio selection + aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) { + state.authorAspectRatio = value + }) + if state.authorAspectRatio == "" { + aspectSelect.SetSelected("AUTO") + } else { + aspectSelect.SetSelected(state.authorAspectRatio) + } + + // DVD title entry + titleEntry := widget.NewEntry() + titleEntry.SetPlaceHolder("DVD Title") + titleEntry.SetText(state.authorTitle) + titleEntry.OnChanged = func(value string) { + state.authorTitle = value + } + + // Create menu checkbox + createMenuCheck := widget.NewCheck("Create DVD Menu", state.authorCreateMenu) + createMenuCheck.OnChanged = func(checked bool) { + state.authorCreateMenu = checked + } + + controls := container.NewVBox( + widget.NewLabel("Output Settings:"), + widget.NewSeparator(), + widget.NewLabel("Output Type:"), + outputType, + widget.NewLabel("Region:"), + regionSelect, + widget.NewLabel("Aspect Ratio:"), + aspectSelect, + widget.NewLabel("DVD Title:"), + titleEntry, + createMenuCheck, + ) + + return container.NewPadded(controls) +} + func buildAuthorDiscTab(state *appState) fyne.CanvasObject { - placeholder := widget.NewLabel("Disc authoring will be implemented here.\n\nFeatures:\n• Create VIDEO_TS folder structure\n• Generate burn-ready ISO\n• NTSC/PAL selection\n• Menu creation\n• Chapter integration") - placeholder.Wrapping = fyne.TextWrapWord - return container.NewCenter(placeholder) + // Generate DVD/ISO + generateBtn := widget.NewButton("GENERATE DVD", func() { + if len(state.authorClips) == 0 && state.authorFile == nil { + dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window) + return + } + + // Show compilation options + dialog.ShowFormConfirm("Generate DVD", + "Choose generation options:", + func(callback bool, options map[string]interface{}) { + if !callback { + return + } + // TODO: Implement actual DVD/ISO generation + dialog.ShowInformation("DVD Generation", "DVD/ISO generation will be implemented in next step", state.window) + }, + map[string]string{ + "include_subtitles": "Include Subtitles", + "include_chapters": "Include Chapters", + "preserve_quality": "Preserve Original Quality", + }, + map[string]interface{}{ + "include_subtitles": len(state.authorSubtitles) > 0, + "include_chapters": len(state.authorChapters) > 0, + "preserve_quality": true, + }, + state.window) + }) + generateBtn.Importance = widget.HighImportance + + // Show summary + summary := "Ready to generate:\n\n" + if len(state.authorClips) > 0 { + summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips)) + for i, clip := range state.authorClips { + summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration) + } + } else if state.authorFile != nil { + summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path)) + } + + if len(state.authorSubtitles) > 0 { + summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles)) + for i, path := range state.authorSubtitles { + summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path)) + } + } + + summary += fmt.Sprintf("Output Type: %s\n", state.authorOutputType) + summary += fmt.Sprintf("Region: %s\n", state.authorRegion) + summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio) + if state.authorTitle != "" { + summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle) + } + + summaryLabel := widget.NewLabel(summary) + summaryLabel.Wrapping = fyne.TextWrapWord + + controls := container.NewVBox( + widget.NewLabel("Generate DVD/ISO:"), + widget.NewSeparator(), + summaryLabel, + widget.NewSeparator(), + generateBtn, + ) + + return container.NewPadded(controls) +} + +func buildVideoClipsTab(state *appState) fyne.CanvasObject { + // Video clips list with drag-and-drop support + list := container.NewVBox() + + rebuildList := func() { + list.Objects = nil + + if len(state.authorClips) == 0 { + emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos") + emptyLabel.Alignment = fyne.TextAlignCenter + + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + state.addAuthorFiles(paths) + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, clip := range state.authorClips { + idx := i + card := widget.NewCard(clip.DisplayName, fmt.Sprintf("%.2fs", clip.Duration), nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) + rebuildList() + }) + removeBtn.Importance = widget.MediumImportance + + // Duration label + durationLabel := widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", clip.Duration)) + durationLabel.TextStyle = fyne.TextStyle{Italic: true} + + cardContent := container.NewVBox( + durationLabel, + widget.NewSeparator(), + removeBtn, + ) + card.SetContent(cardContent) + list.Add(card) + } + } + } + + // Add files button + addBtn := widget.NewButton("Add Files", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.addAuthorFiles([]string{reader.URI().Path()}) + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorClips = []authorClip{} + rebuildList() + }) + clearBtn.Importance = widget.MediumImportance + + // Compile button + compileBtn := widget.NewButton("COMPILE TO DVD", func() { + if len(state.authorClips) == 0 { + dialog.ShowInformation("No Clips", "Please add video clips first", state.window) + return + } + // TODO: Implement compilation to DVD + dialog.ShowInformation("Compile", "DVD compilation will be implemented", state.window) + }) + compileBtn.Importance = widget.HighImportance + + controls := container.NewVBox( + widget.NewLabel("Video Clips:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn, compileBtn), + ) + + // Initialize the list + rebuildList() + + return container.NewPadded(controls) +} + +func buildSubtitlesTab(state *appState) fyne.CanvasObject { + // Subtitle files list with drag-and-drop support + list := container.NewVBox() + + rebuildSubList := func() { + list.Objects = nil + + if len(state.authorSubtitles) == 0 { + emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select") + emptyLabel.Alignment = fyne.TextAlignCenter + + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + state.authorSubtitles = append(state.authorSubtitles, paths...) + rebuildSubList() + } + }) + + list.Add(container.NewMax(emptyDrop)) + } else { + for i, path := range state.authorSubtitles { + idx := i + card := widget.NewCard(filepath.Base(path), "", nil) + + // Remove button + removeBtn := widget.NewButton("Remove", func() { + state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...) + rebuildSubList() + }) + removeBtn.Importance = widget.MediumImportance + + cardContent := container.NewVBox(removeBtn) + card.SetContent(cardContent) + list.Add(card) + } + } + } + + // Add subtitles button + addBtn := widget.NewButton("Add Subtitles", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path()) + rebuildSubList() + }, state.window) + }) + addBtn.Importance = widget.HighImportance + + // Clear all button + clearBtn := widget.NewButton("Clear All", func() { + state.authorSubtitles = []string{} + rebuildSubList() + }) + clearBtn.Importance = widget.MediumImportance + + controls := container.NewVBox( + widget.NewLabel("Subtitle Tracks:"), + container.NewScroll(list), + widget.NewSeparator(), + container.NewHBox(addBtn, clearBtn), + ) + + // Initialize + rebuildSubList() + + return container.NewPadded(controls) +} + +func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { + // Output type selection + outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}, func(value string) { + if value == "DVD (VIDEO_TS)" { + state.authorOutputType = "dvd" + } else { + state.authorOutputType = "iso" + } + }) + if state.authorOutputType == "iso" { + outputType.SetSelected("ISO Image") + } + + // Region selection + regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) { + state.authorRegion = value + }) + if state.authorRegion == "" { + regionSelect.SetSelected("AUTO") + } else { + regionSelect.SetSelected(state.authorRegion) + } + + // Aspect ratio selection + aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) { + state.authorAspectRatio = value + }) + if state.authorAspectRatio == "" { + aspectSelect.SetSelected("AUTO") + } else { + aspectSelect.SetSelected(state.authorAspectRatio) + } + + // DVD title entry + titleEntry := widget.NewEntry() + titleEntry.SetPlaceHolder("DVD Title") + titleEntry.SetText(state.authorTitle) + titleEntry.OnChanged = func(value string) { + state.authorTitle = value + } + + // Create menu checkbox + createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) { + state.authorCreateMenu = checked + }) + createMenuCheck.SetChecked(state.authorCreateMenu) + + controls := container.NewVBox( + widget.NewLabel("Output Settings:"), + widget.NewSeparator(), + widget.NewLabel("Output Type:"), + outputType, + widget.NewLabel("Region:"), + regionSelect, + widget.NewLabel("Aspect Ratio:"), + aspectSelect, + widget.NewLabel("DVD Title:"), + titleEntry, + createMenuCheck, + ) + + return container.NewPadded(controls) +} + +func buildAuthorDiscTab(state *appState) fyne.CanvasObject { + // Generate DVD/ISO + generateBtn := widget.NewButton("GENERATE DVD", func() { + if len(state.authorClips) == 0 && state.authorFile == nil { + dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window) + return + } + + // Show compilation options + dialog.ShowInformation("DVD Generation", + "DVD/ISO generation will be implemented in next step.\n\n"+ + "Features planned:\n"+ + "• Create VIDEO_TS folder structure\n"+ + "• Generate burn-ready ISO\n"+ + "• Include subtitle tracks\n"+ + "• Include alternate audio tracks\n"+ + "• Support for alternate camera angles", state.window) + }) + generateBtn.Importance = widget.HighImportance + + // Show summary + summary := "Ready to generate:\n\n" + if len(state.authorClips) > 0 { + summary += fmt.Sprintf("Video Clips: %d\n", len(state.authorClips)) + for i, clip := range state.authorClips { + summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration) + } + } else if state.authorFile != nil { + summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path)) + } + + if len(state.authorSubtitles) > 0 { + summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles)) + for i, path := range state.authorSubtitles { + summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path)) + } + } + + summary += fmt.Sprintf("Output Type: %s\n", state.authorOutputType) + summary += fmt.Sprintf("Region: %s\n", state.authorRegion) + summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio) + if state.authorTitle != "" { + summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle) + } + + summaryLabel := widget.NewLabel(summary) + summaryLabel.Wrapping = fyne.TextWrapWord + + controls := container.NewVBox( + widget.NewLabel("Generate DVD/ISO:"), + widget.NewSeparator(), + summaryLabel, + widget.NewSeparator(), + generateBtn, + ) + + return container.NewPadded(controls) +} + +// addAuthorFiles helper function +func (s *appState) addAuthorFiles(paths []string) { + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window) + continue + } + + clip := authorClip{ + Path: path, + DisplayName: filepath.Base(path), + Duration: src.Duration, + Chapters: []authorChapter{}, + } + s.authorClips = append(s.authorClips, clip) + } } func buildUpscaleFilter(targetWidth, targetHeight int, method string, preserveAspect bool) string {