diff --git a/author_module.go b/author_module.go index 090b978..5798aac 100644 --- a/author_module.go +++ b/author_module.go @@ -8,6 +8,7 @@ import ( "encoding/xml" "errors" "fmt" + "image/color" "io" "math" "os" @@ -46,6 +47,9 @@ type authorConfig struct { MenuLogoPosition string `json:"menuLogoPosition"` MenuLogoScale float64 `json:"menuLogoScale"` MenuLogoMargin int `json:"menuLogoMargin"` + MenuStructure string `json:"menuStructure"` + MenuExtrasEnabled bool `json:"menuExtrasEnabled"` + MenuChapterThumbSrc string `json:"menuChapterThumbSrc"` TreatAsChapters bool `json:"treatAsChapters"` SceneThreshold float64 `json:"sceneThreshold"` } @@ -66,6 +70,9 @@ func defaultAuthorConfig() authorConfig { MenuLogoPosition: "Top Right", MenuLogoScale: 1.0, MenuLogoMargin: 24, + MenuStructure: "Feature + Chapters", + MenuExtrasEnabled: false, + MenuChapterThumbSrc: "Auto", TreatAsChapters: false, SceneThreshold: 0.3, } @@ -108,6 +115,12 @@ func loadPersistedAuthorConfig() (authorConfig, error) { if cfg.MenuLogoMargin == 0 { cfg.MenuLogoMargin = 24 } + if cfg.MenuStructure == "" { + cfg.MenuStructure = "Feature + Chapters" + } + if cfg.MenuChapterThumbSrc == "" { + cfg.MenuChapterThumbSrc = "Auto" + } if cfg.SceneThreshold <= 0 { cfg.SceneThreshold = 0.3 } @@ -141,6 +154,9 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) { s.authorMenuLogoPosition = cfg.MenuLogoPosition s.authorMenuLogoScale = cfg.MenuLogoScale s.authorMenuLogoMargin = cfg.MenuLogoMargin + s.authorMenuStructure = cfg.MenuStructure + s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled + s.authorMenuChapterThumbSrc = cfg.MenuChapterThumbSrc s.authorTreatAsChapters = cfg.TreatAsChapters s.authorSceneThreshold = cfg.SceneThreshold } @@ -161,6 +177,9 @@ func (s *appState) persistAuthorConfig() { MenuLogoPosition: s.authorMenuLogoPosition, MenuLogoScale: s.authorMenuLogoScale, MenuLogoMargin: s.authorMenuLogoMargin, + MenuStructure: s.authorMenuStructure, + MenuExtrasEnabled: s.authorMenuExtrasEnabled, + MenuChapterThumbSrc: s.authorMenuChapterThumbSrc, TreatAsChapters: s.authorTreatAsChapters, SceneThreshold: s.authorSceneThreshold, } @@ -205,6 +224,12 @@ func buildAuthorView(state *appState) fyne.CanvasObject { if state.authorMenuLogoMargin == 0 { state.authorMenuLogoMargin = 24 } + if state.authorMenuStructure == "" { + state.authorMenuStructure = "Feature + Chapters" + } + if state.authorMenuChapterThumbSrc == "" { + state.authorMenuChapterThumbSrc = "Auto" + } authorColor := moduleColor("author") @@ -849,6 +874,9 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { MenuLogoPosition: state.authorMenuLogoPosition, MenuLogoScale: state.authorMenuLogoScale, MenuLogoMargin: state.authorMenuLogoMargin, + MenuStructure: state.authorMenuStructure, + MenuExtrasEnabled: state.authorMenuExtrasEnabled, + MenuChapterThumbSrc: state.authorMenuChapterThumbSrc, TreatAsChapters: state.authorTreatAsChapters, SceneThreshold: state.authorSceneThreshold, } @@ -893,12 +921,41 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { } func buildAuthorMenuTab(state *appState) fyne.CanvasObject { + navyBlue := utils.MustHex("#191F35") + boxAccent := gridColor + var updateMenuControls func(bool) + + buildMenuBox := func(title string, content fyne.CanvasObject) fyne.CanvasObject { + bg := canvas.NewRectangle(navyBlue) + bg.CornerRadius = 10 + bg.StrokeColor = boxAccent + bg.StrokeWidth = 1 + body := container.NewVBox( + widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + widget.NewSeparator(), + content, + ) + return container.NewMax(bg, container.NewPadded(body)) + } + + sectionGap := func() fyne.CanvasObject { + gap := canvas.NewRectangle(color.Transparent) + gap.SetMinSize(fyne.NewSize(0, 10)) + return gap + } + createMenuCheck := widget.NewCheck("Enable DVD Menus", func(checked bool) { state.authorCreateMenu = checked state.updateAuthorSummary() state.persistAuthorConfig() + if updateMenuControls != nil { + updateMenuControls(checked) + } }) createMenuCheck.SetChecked(state.authorCreateMenu) + menuDisabledNote := widget.NewLabel("DVD menus will not be generated unless enabled.") + menuDisabledNote.TextStyle = fyne.TextStyle{Italic: true} + menuDisabledNote.Wrapping = fyne.TextWrapWord menuThemeSelect := widget.NewSelect([]string{"VideoTools"}, func(value string) { state.authorMenuTheme = value @@ -910,15 +967,36 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { } menuThemeSelect.SetSelected(state.authorMenuTheme) - menuTemplateSelect := widget.NewSelect([]string{"Simple", "Dark", "Poster"}, func(value string) { - state.authorMenuTemplate = value + templateOptions := []string{ + "Simple (Title + Buttons)", + "Poster (Grid Thumbnails)", + "Classic (Title + Buttons)", + } + templateValueByLabel := map[string]string{ + "Simple (Title + Buttons)": "Simple", + "Poster (Grid Thumbnails)": "Poster", + "Classic (Title + Buttons)": "Dark", + } + templateLabelByValue := map[string]string{ + "Simple": "Simple (Title + Buttons)", + "Poster": "Poster (Grid Thumbnails)", + "Dark": "Classic (Title + Buttons)", + } + + menuTemplateSelect := widget.NewSelect(templateOptions, func(value string) { + state.authorMenuTemplate = templateValueByLabel[value] state.updateAuthorSummary() state.persistAuthorConfig() }) if state.authorMenuTemplate == "" { state.authorMenuTemplate = "Simple" } - menuTemplateSelect.SetSelected(state.authorMenuTemplate) + templateLabel := templateLabelByValue[state.authorMenuTemplate] + if templateLabel == "" { + templateLabel = templateOptions[0] + state.authorMenuTemplate = templateValueByLabel[templateLabel] + } + menuTemplateSelect.SetSelected(templateLabel) bgImageLabel := widget.NewLabel(state.authorMenuBackgroundImage) bgImageLabel.Wrapping = fyne.TextWrapWord @@ -939,19 +1017,15 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { bgImageLabel.Hidden = state.authorMenuTemplate != "Poster" menuTemplateSelect.OnChanged = func(value string) { - state.authorMenuTemplate = value - showPoster := value == "Poster" + state.authorMenuTemplate = templateValueByLabel[value] + showPoster := state.authorMenuTemplate == "Poster" bgImageButton.Hidden = !showPoster bgImageLabel.Hidden = !showPoster state.updateAuthorSummary() state.persistAuthorConfig() } - logoEnableCheck := widget.NewCheck("Embed Logo", func(checked bool) { - state.authorMenuLogoEnabled = checked - state.updateAuthorSummary() - state.persistAuthorConfig() - }) + logoEnableCheck := widget.NewCheck("Embed Logo", nil) logoEnableCheck.SetChecked(state.authorMenuLogoEnabled) logoLabel := widget.NewLabel(state.authorMenuLogoPath) @@ -985,18 +1059,38 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { } logoPositionSelect.SetSelected(state.authorMenuLogoPosition) + scaleOptions := []string{"50%", "75%", "100%", "125%", "150%", "200%"} + scaleValueByLabel := map[string]float64{ + "50%": 0.5, + "75%": 0.75, + "100%": 1.0, + "125%": 1.25, + "150%": 1.5, + "200%": 2.0, + } + scaleLabelByValue := map[float64]string{ + 0.5: "50%", + 0.75: "75%", + 1.0: "100%", + 1.25: "125%", + 1.5: "150%", + 2.0: "200%", + } if state.authorMenuLogoScale == 0 { state.authorMenuLogoScale = 1.0 } - scaleLabel := widget.NewLabel(fmt.Sprintf("Logo Scale: %.0f%%", state.authorMenuLogoScale*100)) - scaleSlider := widget.NewSlider(0.2, 2.0) - scaleSlider.Step = 0.05 - scaleSlider.Value = state.authorMenuLogoScale - scaleSlider.OnChanged = func(v float64) { - state.authorMenuLogoScale = v - scaleLabel.SetText(fmt.Sprintf("Logo Scale: %.0f%%", v*100)) - state.persistAuthorConfig() + logoScaleSelect := widget.NewSelect(scaleOptions, func(value string) { + if scale, ok := scaleValueByLabel[value]; ok { + state.authorMenuLogoScale = scale + state.persistAuthorConfig() + } + }) + scaleLabel := scaleLabelByValue[state.authorMenuLogoScale] + if scaleLabel == "" { + scaleLabel = "100%" + state.authorMenuLogoScale = 1.0 } + logoScaleSelect.SetSelected(scaleLabel) if state.authorMenuLogoMargin == 0 { state.authorMenuLogoMargin = 24 @@ -1011,34 +1105,141 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { state.persistAuthorConfig() } - info := widget.NewLabel("DVD menus use the VideoTools theme and IBM Plex Mono. Menu settings apply only to disc authoring.") + safeAreaNote := widget.NewLabel("Logos are constrained to DVD safe areas.") + safeAreaNote.TextStyle = fyne.TextStyle{Italic: true} + safeAreaNote.Wrapping = fyne.TextWrapWord + + menuStructureSelect := widget.NewSelect([]string{ + "Feature Only", + "Feature + Chapters", + "Feature + Extras", + "Feature + Chapters + Extras", + }, func(value string) { + state.authorMenuStructure = value + state.persistAuthorConfig() + }) + if state.authorMenuStructure == "" { + state.authorMenuStructure = "Feature + Chapters" + } + menuStructureSelect.SetSelected(state.authorMenuStructure) + + extrasMenuCheck := widget.NewCheck("Enable Extras Menu", func(checked bool) { + state.authorMenuExtrasEnabled = checked + state.persistAuthorConfig() + }) + extrasMenuCheck.SetChecked(state.authorMenuExtrasEnabled) + extrasNote := widget.NewLabel("Videos marked as Extras appear in a separate menu.") + extrasNote.Wrapping = fyne.TextWrapWord + + thumbSourceSelect := widget.NewSelect([]string{ + "Auto", + "First Frame", + "Midpoint", + "Custom (Advanced)", + }, func(value string) { + state.authorMenuChapterThumbSrc = value + state.persistAuthorConfig() + }) + if state.authorMenuChapterThumbSrc == "" { + state.authorMenuChapterThumbSrc = "Auto" + } + thumbSourceSelect.SetSelected(state.authorMenuChapterThumbSrc) + + info := widget.NewLabel("DVD menus are generated using the VideoTools theme and IBM Plex Mono. Menu settings apply only to disc authoring.") info.Wrapping = fyne.TextWrapWord - controls := container.NewVBox( - widget.NewLabel("DVD Menu Settings:"), - widget.NewSeparator(), + previewBox := buildMenuBox("Preview", widget.NewLabel("Menu preview is generated during authoring.")) + + menuCore := buildMenuBox("Menu Core", container.NewVBox( createMenuCheck, + menuDisabledNote, widget.NewLabel("Theme:"), menuThemeSelect, widget.NewLabel("Template:"), menuTemplateSelect, bgImageLabel, bgImageButton, - widget.NewSeparator(), + widget.NewLabel("Menu Structure:"), + menuStructureSelect, + )) + + branding := buildMenuBox("Branding", container.NewVBox( logoEnableCheck, widget.NewLabel("Logo Path:"), logoLabel, logoPickButton, widget.NewLabel("Logo Position:"), logoPositionSelect, - scaleLabel, - scaleSlider, + widget.NewLabel("Logo Scale:"), + logoScaleSelect, marginLabel, marginSlider, - widget.NewSeparator(), + safeAreaNote, + )) + + navigation := buildMenuBox("Navigation", container.NewVBox( + extrasMenuCheck, + extrasNote, + widget.NewLabel("Chapter Thumbnail Source:"), + thumbSourceSelect, + )) + + controls := container.NewVBox( + menuCore, + sectionGap(), + branding, + sectionGap(), + navigation, + sectionGap(), + previewBox, + sectionGap(), info, ) + updateMenuControls = func(enabled bool) { + menuDisabledNote.Hidden = enabled + setEnabled := func(on bool, items ...fyne.Disableable) { + for _, item := range items { + if on { + item.Enable() + } else { + item.Disable() + } + } + } + + setEnabled(enabled, + menuThemeSelect, + menuTemplateSelect, + bgImageButton, + menuStructureSelect, + logoEnableCheck, + logoPickButton, + logoPositionSelect, + logoScaleSelect, + marginSlider, + extrasMenuCheck, + thumbSourceSelect, + ) + + logoControlsEnabled := enabled && state.authorMenuLogoEnabled + setEnabled(logoControlsEnabled, + logoPickButton, + logoPositionSelect, + logoScaleSelect, + marginSlider, + ) + } + + logoEnableCheck.OnChanged = func(checked bool) { + state.authorMenuLogoEnabled = checked + updateMenuControls(state.authorCreateMenu) + state.updateAuthorSummary() + state.persistAuthorConfig() + } + + updateMenuControls(state.authorCreateMenu) + return container.NewPadded(controls) } @@ -1962,6 +2163,9 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu "menuLogoPosition": s.authorMenuLogoPosition, "menuLogoScale": s.authorMenuLogoScale, "menuLogoMargin": s.authorMenuLogoMargin, + "menuStructure": s.authorMenuStructure, + "menuExtrasEnabled": s.authorMenuExtrasEnabled, + "menuChapterThumbSrc": s.authorMenuChapterThumbSrc, } titleLabel := title diff --git a/docs/AUTHOR_MODULE.md b/docs/AUTHOR_MODULE.md index 6556083..3d3640b 100644 --- a/docs/AUTHOR_MODULE.md +++ b/docs/AUTHOR_MODULE.md @@ -156,12 +156,14 @@ You filmed stuff on your phone and want to give it to relatives who don't use co - Output Type: DVD - Region: NTSC - Aspect Ratio: 16:9 -5. **Generate Tab**: +5. **Menu Tab** (optional): + - Enable DVD Menus if you want a playable menu +6. **Generate Tab**: - Title: "Birthday 2024" - Pick where to save it - Click Generate -6. When done, burn the .iso file to a DVD-R -7. Hand it to grandma - it'll just work in her DVD player +7. When done, burn the .iso file to a DVD-R +8. Hand it to grandma - it'll just work in her DVD player ### Scenario 2: Multiple Episodes on One Disc @@ -172,9 +174,11 @@ You downloaded 3 episodes of a show and want them on one disc with a menu. 3. **Settings Tab**: - Output Type: DVD - Region: NTSC - - Create Menu: YES (important!) -4. **Generate Tab** → Generate the disc -5. The DVD will have a menu where you can pick which episode to watch +4. **Menu Tab**: + - Enable DVD Menus + - Menu Structure: Feature + Extras +5. **Generate Tab** → Generate the disc +6. The DVD will have a menu where you can pick which episode to watch ### Scenario 3: Concert Video with Song Chapters diff --git a/internal/player/unified_ffmpeg_player.go b/internal/player/unified_ffmpeg_player.go index d25da15..62c4ace 100644 --- a/internal/player/unified_ffmpeg_player.go +++ b/internal/player/unified_ffmpeg_player.go @@ -12,6 +12,8 @@ import ( "sync" "time" + "github.com/ebitengine/oto/v3" + "git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/utils" ) @@ -35,6 +37,10 @@ type UnifiedPlayer struct { audioPipeReader *io.PipeReader audioPipeWriter *io.PipeWriter + // Audio output + audioContext *oto.Context + audioPlayer *oto.Player + // State tracking currentPath string currentTime time.Duration @@ -137,8 +143,40 @@ func (p *UnifiedPlayer) Load(path string, offset time.Duration) error { } } + // Initialize audio context for playback + sampleRate := 48000 + channels := 2 + bytesPerSample := 2 // 16-bit = 2 bytes + + ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ + SampleRate: sampleRate, + ChannelCount: channels, + Format: oto.FormatSignedInt16LE, + BufferSize: 4096, // 85ms chunks for smooth playback + }) + if err != nil { + logging.Error(logging.CatPlayer, "Failed to create audio context: %v", err) + return err + } + if ready != nil { + <-ready + } + + p.audioContext = ctx + + // Initialize audio buffer + p.audioBuffer = make([]byte, 0, 0) // Will grow as needed + // Start FFmpeg process for unified A/V output - return p.startVideoProcess() + err = p.startVideoProcess() + if err != nil { + return err + } + + // Start audio stream processing + go p.readAudioStream() + + return nil } // SeekToTime seeks to a specific time without restarting processes @@ -396,6 +434,15 @@ func (p *UnifiedPlayer) Close() { p.frameBuffer = nil p.audioBuffer = nil + + // Close audio context and player + if p.audioContext != nil { + p.audioContext = nil + } + if p.audioPlayer != nil { + p.audioPlayer.Close() + p.audioPlayer = nil + } } // Stop halts playback and tears down the FFmpeg process. @@ -585,14 +632,25 @@ func (p *UnifiedPlayer) readAudioStream() { continue } - // Apply volume if not muted - if !p.muted && p.volume > 0 { - p.applyVolumeToBuffer(buffer[:n]) + // Initialize audio player if needed + if p.audioPlayer == nil && p.audioContext != nil { + player, err := p.audioContext.NewPlayer(p.audioPipeReader) + if err != nil { + logging.Error(logging.CatPlayer, "Failed to create audio player: %v", err) + return + } + p.audioPlayer = player } - // Send to audio output (this would connect to audio system) - // For now, we'll store in buffer for playback sync monitoring - p.audioBuffer = append(p.audioBuffer, buffer[:n]...) + // Write audio data to player buffer + if p.audioPlayer != nil { + p.audioPlayer.Write(buffer[:n]) + } + + // Buffer for sync monitoring (keep small to avoid memory issues) + if len(p.audioBuffer) > 32768 { // Max 1 second at 48kHz + p.audioBuffer = p.audioBuffer[len(p.audioBuffer)-16384:] // Keep half + } // Simple audio sync timing p.updateAVSync() @@ -611,7 +669,12 @@ func (p *UnifiedPlayer) readVideoFrame() (*image.RGBA, error) { frameSize := p.windowW * p.windowH * 3 // RGB24 = 3 bytes per pixel frameData := make([]byte, frameSize) - // Read full frame - io.ReadFull ensures we get the complete frame + // Check for paused state before reading + if p.paused { + return nil, fmt.Errorf("player is paused") + } + + // Read full frame - io.ReadFull ensures we get complete frame n, err := io.ReadFull(p.videoPipeReader, frameData) if err != nil { if err == io.EOF || err == io.ErrUnexpectedEOF { diff --git a/main.go b/main.go index 75e2341..87ddc12 100644 --- a/main.go +++ b/main.go @@ -1157,6 +1157,9 @@ type appState struct { authorMenuLogoPosition string // "Top Left", "Top Right", "Bottom Left", "Bottom Right", "Center" authorMenuLogoScale float64 authorMenuLogoMargin int + authorMenuStructure string // Feature only, Chapters, Extras + authorMenuExtrasEnabled bool // Show extras menu + authorMenuChapterThumbSrc string // Auto, First Frame, Midpoint, Custom authorTitle string // DVD title authorSubtitles []string // Subtitle file paths authorAudioTracks []string // Additional audio tracks