From 46d8bd0f93e2bda7e0d81982e43ac4fb85706d6a Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 6 Jan 2026 21:04:58 -0500 Subject: [PATCH] Add DVD menu tab with theme and logo controls --- author_menu.go | 251 ++++++++++---- author_module.go | 385 +++++++++++++++++----- internal/player/unified_ffmpeg_player.go | 181 ++++++++-- internal/player/unified_player_adapter.go | 64 +++- main.go | 18 + 5 files changed, 717 insertions(+), 182 deletions(-) diff --git a/author_menu.go b/author_menu.go index 5b66fb8..b63cc4c 100644 --- a/author_menu.go +++ b/author_menu.go @@ -21,9 +21,27 @@ type dvdMenuButton struct { Y1 int } +type menuTheme struct { + Name string + BackgroundColor string + HeaderColor string + TextColor string + AccentColor string + FontName string + FontPath string +} + +type menuLogoOptions struct { + Enabled bool + Path string + Position string + Scale float64 + Margin int +} + // MenuTemplate defines the interface for a DVD menu generator. type MenuTemplate interface { - Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error) + Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, theme *menuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) } var menuTemplates = map[string]MenuTemplate{ @@ -32,11 +50,23 @@ var menuTemplates = map[string]MenuTemplate{ "Poster": &PosterMenu{}, } +var menuThemes = map[string]*menuTheme{ + "VideoTools": { + Name: "VideoTools", + BackgroundColor: "0x0f172a", + HeaderColor: "0x1f2937", + TextColor: "0xE1EEFF", + AccentColor: "0x7c3aed", + FontName: "IBM Plex Mono", + FontPath: findMenuFontPath(), + }, +} + // SimpleMenu is a basic menu template. type SimpleMenu struct{} // Generate creates a simple DVD menu. -func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error) { +func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, theme *menuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) buttons := buildDVDMenuButtons(chapters, width, height) if len(buttons) == 0 { @@ -59,12 +89,12 @@ func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspec } if backgroundImage == "" { - if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height); err != nil { + if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logo); err != nil { return "", nil, err } } - if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil { + if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme)); err != nil { return "", nil, err } if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil { @@ -86,7 +116,7 @@ func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspec type DarkMenu struct{} // Generate creates a dark-themed DVD menu. -func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error) { +func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, theme *menuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) buttons := buildDVDMenuButtons(chapters, width, height) if len(buttons) == 0 { @@ -109,12 +139,12 @@ func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect } if backgroundImage == "" { - if err := buildDarkMenuBackground(ctx, bgPath, title, buttons, width, height); err != nil { + if err := buildDarkMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logo); err != nil { return "", nil, err } } - if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil { + if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme)); err != nil { return "", nil, err } if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil { @@ -136,7 +166,7 @@ func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect type PosterMenu struct{} // Generate creates a poster-themed DVD menu. -func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error) { +func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, theme *menuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) buttons := buildDVDMenuButtons(chapters, width, height) if len(buttons) == 0 { @@ -158,11 +188,11 @@ func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspec logFn("Building DVD menu assets with PosterMenu template...") } - if err := buildPosterMenuBackground(ctx, bgPath, title, buttons, width, height, backgroundImage); err != nil { + if err := buildPosterMenuBackground(ctx, bgPath, title, buttons, width, height, backgroundImage, resolveMenuTheme(theme), logo); err != nil { return "", nil, err } - if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil { + if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme)); err != nil { return "", nil, err } if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil { @@ -180,11 +210,11 @@ func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspec return menuSpu, buttons, nil } -func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, logFn func(string), template MenuTemplate, backgroundImage string) (string, []dvdMenuButton, error) { +func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, logFn func(string), template MenuTemplate, backgroundImage string, theme *menuTheme, logo menuLogoOptions) (string, []dvdMenuButton, error) { if template == nil { template = &SimpleMenu{} } - return template.Generate(ctx, workDir, title, region, aspect, chapters, backgroundImage, logFn) + return template.Generate(ctx, workDir, title, region, aspect, chapters, backgroundImage, resolveMenuTheme(theme), logo, logFn) } func dvdMenuDimensions(region string) (int, int) { @@ -232,55 +262,53 @@ func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuB return buttons } -func buildMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int) error { - logoPath := findVTLogoPath() - if logoPath == "" { - return fmt.Errorf("VT logo not found for menu rendering") - } +func buildMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *menuTheme, logo menuLogoOptions) error { + theme = resolveMenuTheme(theme) safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40) if safeTitle == "" { safeTitle = "DVD Menu" } - bgColor := "0x0f172a" - headerColor := "0x1f2937" - textColor := "white" - accentColor := "0x7c3aed" + bgColor := theme.BackgroundColor + headerColor := theme.HeaderColor + textColor := theme.TextColor + accentColor := theme.AccentColor + fontArg := menuFontArg(theme) filterParts := []string{ fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", fontArg, textColor, escapeDrawtextText(safeTitle)), fmt.Sprintf("drawbox=x=36:y=108:w=%d:h=2:color=%s:t=fill", width-72, accentColor), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", textColor, escapeDrawtextText("Select a title or chapter to play")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", fontArg, textColor, escapeDrawtextText("Select a title or chapter to play")), } for i, btn := range buttons { label := escapeDrawtextText(btn.Label) y := 184 + i*34 - filterParts = append(filterParts, fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", textColor, y, label)) + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", fontArg, textColor, y, label)) } filterChain := strings.Join(filterParts, ",") - args := []string{ - "-y", - "-f", "lavfi", - "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height), - "-i", logoPath, - "-filter_complex", fmt.Sprintf("[0:v]%s[bg];[1:v]scale=72:-1[logo];[bg][logo]overlay=W-w-36:18", filterChain), - "-frames:v", "1", - outputPath, + args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} + filterExpr := fmt.Sprintf("[0:v]%s[bg]", filterChain) + if logo.Enabled { + logoPath := resolveMenuLogoPath(logo) + if logoPath != "" { + posExpr := resolveMenuLogoPosition(logo, width, height) + scaleExpr := fmt.Sprintf("scale=iw*%.2f:ih*%.2f", resolveMenuLogoScale(logo), resolveMenuLogoScale(logo)) + args = append(args, "-i", logoPath) + filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr) + } } + args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil) } -func buildDarkMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int) error { - logoPath := findVTLogoPath() - if logoPath == "" { - return fmt.Errorf("VT logo not found for menu rendering") - } +func buildDarkMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *menuTheme, logo menuLogoOptions) error { + theme = resolveMenuTheme(theme) safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40) if safeTitle == "" { @@ -289,76 +317,89 @@ func buildDarkMenuBackground(ctx context.Context, outputPath, title string, butt bgColor := "0x000000" headerColor := "0x111111" - textColor := "white" - accentColor := "0xeeeeee" + textColor := theme.TextColor + accentColor := theme.AccentColor + fontArg := menuFontArg(theme) filterParts := []string{ fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", fontArg, textColor, escapeDrawtextText(safeTitle)), fmt.Sprintf("drawbox=x=36:y=108:w=%d:h=2:color=%s:t=fill", width-72, accentColor), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", textColor, escapeDrawtextText("Select a title or chapter to play")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", fontArg, textColor, escapeDrawtextText("Select a title or chapter to play")), } for i, btn := range buttons { label := escapeDrawtextText(btn.Label) y := 184 + i*34 - filterParts = append(filterParts, fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", textColor, y, label)) + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", fontArg, textColor, y, label)) } filterChain := strings.Join(filterParts, ",") - args := []string{ - "-y", - "-f", "lavfi", - "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height), - "-i", logoPath, - "-filter_complex", fmt.Sprintf("[0:v]%s[bg];[1:v]scale=72:-1[logo];[bg][logo]overlay=W-w-36:18", filterChain), - "-frames:v", "1", - outputPath, + args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} + filterExpr := fmt.Sprintf("[0:v]%s[bg]", filterChain) + if logo.Enabled { + logoPath := resolveMenuLogoPath(logo) + if logoPath != "" { + posExpr := resolveMenuLogoPosition(logo, width, height) + scaleExpr := fmt.Sprintf("scale=iw*%.2f:ih*%.2f", resolveMenuLogoScale(logo), resolveMenuLogoScale(logo)) + args = append(args, "-i", logoPath) + filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr) + } } + args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil) } -func buildPosterMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, backgroundImage string) error { +func buildPosterMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, backgroundImage string, theme *menuTheme, logo menuLogoOptions) error { + theme = resolveMenuTheme(theme) safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40) if safeTitle == "" { safeTitle = "DVD Menu" } - textColor := "white" + textColor := theme.TextColor + fontArg := menuFontArg(theme) filterParts := []string{ - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")), - fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", fontArg, textColor, escapeDrawtextText(safeTitle)), } for i, btn := range buttons { label := escapeDrawtextText(btn.Label) y := 184 + i*34 - filterParts = append(filterParts, fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", textColor, y, label)) + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", fontArg, textColor, y, label)) } filterChain := strings.Join(filterParts, ",") - args := []string{ - "-y", - "-i", backgroundImage, - "-vf", fmt.Sprintf("scale=%d:%d,%s", width, height, filterChain), - "-frames:v", "1", - outputPath, + args := []string{"-y", "-i", backgroundImage} + filterExpr := fmt.Sprintf("[0:v]scale=%d:%d,%s[bg]", width, height, filterChain) + if logo.Enabled { + logoPath := resolveMenuLogoPath(logo) + if logoPath != "" { + posExpr := resolveMenuLogoPosition(logo, width, height) + scaleExpr := fmt.Sprintf("scale=iw*%.2f:ih*%.2f", resolveMenuLogoScale(logo), resolveMenuLogoScale(logo)) + args = append(args, "-i", logoPath) + filterExpr = fmt.Sprintf("[0:v]scale=%d:%d,%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", width, height, filterChain, scaleExpr, posExpr) + } } + args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil) } -func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int) error { +func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int, theme *menuTheme) error { + theme = resolveMenuTheme(theme) + accent := theme.AccentColor if err := buildMenuOverlay(ctx, overlayPath, buttons, width, height, "0x000000@0.0"); err != nil { return err } - if err := buildMenuOverlay(ctx, highlightPath, buttons, width, height, "0xf59e0b@0.35"); err != nil { + if err := buildMenuOverlay(ctx, highlightPath, buttons, width, height, fmt.Sprintf("%s@0.35", accent)); err != nil { return err } - if err := buildMenuOverlay(ctx, selectPath, buttons, width, height, "0xf59e0b@0.65"); err != nil { + if err := buildMenuOverlay(ctx, selectPath, buttons, width, height, fmt.Sprintf("%s@0.65", accent)); err != nil { return err } return nil @@ -473,6 +514,84 @@ func findVTLogoPath() string { return "" } +func findMenuFontPath() string { + search := []string{ + filepath.Join("assets", "fonts", "IBMPlexMono-Regular.ttf"), + } + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + search = append(search, filepath.Join(dir, "assets", "fonts", "IBMPlexMono-Regular.ttf")) + } + for _, p := range search { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func resolveMenuTheme(theme *menuTheme) *menuTheme { + if theme == nil { + return menuThemes["VideoTools"] + } + if theme.Name == "" { + return menuThemes["VideoTools"] + } + if resolved, ok := menuThemes[theme.Name]; ok { + return resolved + } + return menuThemes["VideoTools"] +} + +func menuFontArg(theme *menuTheme) string { + if theme != nil && theme.FontPath != "" { + return fmt.Sprintf("fontfile='%s'", theme.FontPath) + } + if theme != nil && theme.FontName != "" { + return fmt.Sprintf("font='%s'", theme.FontName) + } + return "font='DejaVu Sans Mono'" +} + +func resolveMenuLogoPath(logo menuLogoOptions) string { + if strings.TrimSpace(logo.Path) != "" { + return logo.Path + } + return filepath.Join("assets", "logo", "VT_Logo.png") +} + +func resolveMenuLogoScale(logo menuLogoOptions) float64 { + if logo.Scale <= 0 { + return 1.0 + } + if logo.Scale < 0.2 { + return 0.2 + } + if logo.Scale > 2.0 { + return 2.0 + } + return logo.Scale +} + +func resolveMenuLogoPosition(logo menuLogoOptions, width, height int) string { + margin := logo.Margin + if margin < 0 { + margin = 0 + } + switch logo.Position { + case "Top Left": + return fmt.Sprintf("%d:%d", margin, margin) + case "Bottom Left": + return fmt.Sprintf("%d:H-h-%d", margin, margin) + case "Bottom Right": + return fmt.Sprintf("W-w-%d:H-h-%d", margin, margin) + case "Center": + return "(W-w)/2:(H-h)/2" + default: + return fmt.Sprintf("W-w-%d:%d", margin, margin) + } +} + func escapeDrawtextText(text string) string { escaped := strings.ReplaceAll(text, "\\", "\\\\") escaped = strings.ReplaceAll(escaped, ":", "\\:") diff --git a/author_module.go b/author_module.go index d58eb29..9d00567 100644 --- a/author_module.go +++ b/author_module.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "os" "os/exec" "path/filepath" @@ -31,26 +32,42 @@ import ( ) type authorConfig struct { - OutputType string `json:"outputType"` - Region string `json:"region"` - AspectRatio string `json:"aspectRatio"` - DiscSize string `json:"discSize"` - Title string `json:"title"` - CreateMenu bool `json:"createMenu"` - TreatAsChapters bool `json:"treatAsChapters"` - SceneThreshold float64 `json:"sceneThreshold"` + OutputType string `json:"outputType"` + Region string `json:"region"` + AspectRatio string `json:"aspectRatio"` + DiscSize string `json:"discSize"` + Title string `json:"title"` + CreateMenu bool `json:"createMenu"` + MenuTemplate string `json:"menuTemplate"` + MenuTheme string `json:"menuTheme"` + MenuBackgroundImage string `json:"menuBackgroundImage"` + MenuLogoEnabled bool `json:"menuLogoEnabled"` + MenuLogoPath string `json:"menuLogoPath"` + MenuLogoPosition string `json:"menuLogoPosition"` + MenuLogoScale float64 `json:"menuLogoScale"` + MenuLogoMargin int `json:"menuLogoMargin"` + TreatAsChapters bool `json:"treatAsChapters"` + SceneThreshold float64 `json:"sceneThreshold"` } func defaultAuthorConfig() authorConfig { return authorConfig{ - OutputType: "dvd", - Region: "AUTO", - AspectRatio: "AUTO", - DiscSize: "DVD5", - Title: "", - CreateMenu: false, - TreatAsChapters: false, - SceneThreshold: 0.3, + OutputType: "dvd", + Region: "AUTO", + AspectRatio: "AUTO", + DiscSize: "DVD5", + Title: "", + CreateMenu: false, + MenuTemplate: "Simple", + MenuTheme: "VideoTools", + MenuBackgroundImage: "", + MenuLogoEnabled: true, + MenuLogoPath: "", + MenuLogoPosition: "Top Right", + MenuLogoScale: 1.0, + MenuLogoMargin: 24, + TreatAsChapters: false, + SceneThreshold: 0.3, } } @@ -76,6 +93,21 @@ func loadPersistedAuthorConfig() (authorConfig, error) { if cfg.DiscSize == "" { cfg.DiscSize = "DVD5" } + if cfg.MenuTemplate == "" { + cfg.MenuTemplate = "Simple" + } + if cfg.MenuTheme == "" { + cfg.MenuTheme = "VideoTools" + } + if cfg.MenuLogoPosition == "" { + cfg.MenuLogoPosition = "Top Right" + } + if cfg.MenuLogoScale == 0 { + cfg.MenuLogoScale = 1.0 + } + if cfg.MenuLogoMargin == 0 { + cfg.MenuLogoMargin = 24 + } if cfg.SceneThreshold <= 0 { cfg.SceneThreshold = 0.3 } @@ -101,21 +133,36 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) { s.authorDiscSize = cfg.DiscSize s.authorTitle = cfg.Title s.authorCreateMenu = cfg.CreateMenu + s.authorMenuTemplate = cfg.MenuTemplate + s.authorMenuTheme = cfg.MenuTheme + s.authorMenuBackgroundImage = cfg.MenuBackgroundImage + s.authorMenuLogoEnabled = cfg.MenuLogoEnabled + s.authorMenuLogoPath = cfg.MenuLogoPath + s.authorMenuLogoPosition = cfg.MenuLogoPosition + s.authorMenuLogoScale = cfg.MenuLogoScale + s.authorMenuLogoMargin = cfg.MenuLogoMargin s.authorTreatAsChapters = cfg.TreatAsChapters s.authorSceneThreshold = cfg.SceneThreshold - // MenuTemplate field doesn't exist in authorConfig struct - remove this line } func (s *appState) persistAuthorConfig() { cfg := authorConfig{ - OutputType: s.authorOutputType, - Region: s.authorRegion, - AspectRatio: s.authorAspectRatio, - DiscSize: s.authorDiscSize, - Title: s.authorTitle, - CreateMenu: s.authorCreateMenu, - TreatAsChapters: s.authorTreatAsChapters, - SceneThreshold: s.authorSceneThreshold, + OutputType: s.authorOutputType, + Region: s.authorRegion, + AspectRatio: s.authorAspectRatio, + DiscSize: s.authorDiscSize, + Title: s.authorTitle, + CreateMenu: s.authorCreateMenu, + MenuTemplate: s.authorMenuTemplate, + MenuTheme: s.authorMenuTheme, + MenuBackgroundImage: s.authorMenuBackgroundImage, + MenuLogoEnabled: s.authorMenuLogoEnabled, + MenuLogoPath: s.authorMenuLogoPath, + MenuLogoPosition: s.authorMenuLogoPosition, + MenuLogoScale: s.authorMenuLogoScale, + MenuLogoMargin: s.authorMenuLogoMargin, + TreatAsChapters: s.authorTreatAsChapters, + SceneThreshold: s.authorSceneThreshold, } if err := savePersistedAuthorConfig(cfg); err != nil { logging.Debug(logging.CatSystem, "failed to persist author config: %v", err) @@ -143,6 +190,21 @@ func buildAuthorView(state *appState) fyne.CanvasObject { if state.authorDiscSize == "" { state.authorDiscSize = "DVD5" } + if state.authorMenuTemplate == "" { + state.authorMenuTemplate = "Simple" + } + if state.authorMenuTheme == "" { + state.authorMenuTheme = "VideoTools" + } + if state.authorMenuLogoPosition == "" { + state.authorMenuLogoPosition = "Top Right" + } + if state.authorMenuLogoScale == 0 { + state.authorMenuLogoScale = 1.0 + } + if state.authorMenuLogoMargin == 0 { + state.authorMenuLogoMargin = 24 + } authorColor := moduleColor("author") @@ -180,6 +242,7 @@ func buildAuthorView(state *appState) fyne.CanvasObject { container.NewTabItem("Videos", buildVideoClipsTab(state)), container.NewTabItem("Chapters", buildChaptersTab(state)), container.NewTabItem("Subtitles", buildSubtitlesTab(state)), + container.NewTabItem("Menu", buildAuthorMenuTab(state)), container.NewTabItem("Settings", buildAuthorSettingsTab(state)), container.NewTabItem("Generate", buildAuthorDiscTab(state)), ) @@ -720,43 +783,6 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { state.persistAuthorConfig() } - createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) { - state.authorCreateMenu = checked - state.updateAuthorSummary() - state.persistAuthorConfig() - }) - createMenuCheck.SetChecked(state.authorCreateMenu) - - menuTemplateSelect := widget.NewSelect([]string{"Simple", "Dark", "Poster"}, func(value string) { - state.authorMenuTemplate = value - state.updateAuthorSummary() - state.persistAuthorConfig() - }) - menuTemplateSelect.SetSelected(state.authorMenuTemplate) - - bgImageLabel := widget.NewLabel(state.authorMenuBackgroundImage) - bgImageButton := widget.NewButton("Select Background Image", func() { - dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { - if err != nil || reader == nil { - return - } - defer reader.Close() - state.authorMenuBackgroundImage = reader.URI().Path() - bgImageLabel.SetText(state.authorMenuBackgroundImage) - state.updateAuthorSummary() - state.persistAuthorConfig() - }, state.window) - }) - bgImageButton.Importance = widget.HighImportance - bgImageButton.Hidden = state.authorMenuTemplate != "Poster" - - menuTemplateSelect.OnChanged = func(value string) { - state.authorMenuTemplate = value - bgImageButton.Hidden = value != "Poster" - state.updateAuthorSummary() - state.persistAuthorConfig() - } - discSizeSelect := widget.NewSelect([]string{"DVD5", "DVD9"}, func(value string) { state.authorDiscSize = value state.updateAuthorSummary() @@ -790,10 +816,6 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { discSizeSelect.SetSelected(state.authorDiscSize) } titleEntry.SetText(state.authorTitle) - createMenuCheck.SetChecked(state.authorCreateMenu) - menuTemplateSelect.SetSelected(state.authorMenuTemplate) - bgImageLabel.SetText(state.authorMenuBackgroundImage) - bgImageButton.Hidden = state.authorMenuTemplate != "Poster" } loadCfgBtn := widget.NewButton("Load Config", func() { @@ -813,14 +835,22 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { saveCfgBtn := widget.NewButton("Save Config", func() { cfg := authorConfig{ - OutputType: state.authorOutputType, - Region: state.authorRegion, - AspectRatio: state.authorAspectRatio, - DiscSize: state.authorDiscSize, - Title: state.authorTitle, - CreateMenu: state.authorCreateMenu, - TreatAsChapters: state.authorTreatAsChapters, - SceneThreshold: state.authorSceneThreshold, + OutputType: state.authorOutputType, + Region: state.authorRegion, + AspectRatio: state.authorAspectRatio, + DiscSize: state.authorDiscSize, + Title: state.authorTitle, + CreateMenu: state.authorCreateMenu, + MenuTemplate: state.authorMenuTemplate, + MenuTheme: state.authorMenuTheme, + MenuBackgroundImage: state.authorMenuBackgroundImage, + MenuLogoEnabled: state.authorMenuLogoEnabled, + MenuLogoPath: state.authorMenuLogoPath, + MenuLogoPosition: state.authorMenuLogoPosition, + MenuLogoScale: state.authorMenuLogoScale, + MenuLogoMargin: state.authorMenuLogoMargin, + TreatAsChapters: state.authorTreatAsChapters, + SceneThreshold: state.authorSceneThreshold, } if err := savePersistedAuthorConfig(cfg); err != nil { dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window) @@ -853,11 +883,6 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { discSizeSelect, widget.NewLabel("DVD Title:"), titleEntry, - createMenuCheck, - widget.NewLabel("Menu Template:"), - menuTemplateSelect, - bgImageLabel, - bgImageButton, widget.NewSeparator(), info, widget.NewSeparator(), @@ -867,6 +892,156 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { return container.NewPadded(controls) } +func buildAuthorMenuTab(state *appState) fyne.CanvasObject { + createMenuCheck := widget.NewCheck("Enable DVD Menus", func(checked bool) { + state.authorCreateMenu = checked + state.updateAuthorSummary() + state.persistAuthorConfig() + }) + createMenuCheck.SetChecked(state.authorCreateMenu) + + menuThemeSelect := widget.NewSelect([]string{"VideoTools"}, func(value string) { + state.authorMenuTheme = value + state.updateAuthorSummary() + state.persistAuthorConfig() + }) + if state.authorMenuTheme == "" { + state.authorMenuTheme = "VideoTools" + } + menuThemeSelect.SetSelected(state.authorMenuTheme) + + menuTemplateSelect := widget.NewSelect([]string{"Simple", "Dark", "Poster"}, func(value string) { + state.authorMenuTemplate = value + state.updateAuthorSummary() + state.persistAuthorConfig() + }) + if state.authorMenuTemplate == "" { + state.authorMenuTemplate = "Simple" + } + menuTemplateSelect.SetSelected(state.authorMenuTemplate) + + bgImageLabel := widget.NewLabel(state.authorMenuBackgroundImage) + bgImageLabel.Wrapping = fyne.TextWrapWord + bgImageButton := widget.NewButton("Select Background Image", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.authorMenuBackgroundImage = reader.URI().Path() + bgImageLabel.SetText(state.authorMenuBackgroundImage) + state.updateAuthorSummary() + state.persistAuthorConfig() + }, state.window) + }) + bgImageButton.Importance = widget.HighImportance + bgImageButton.Hidden = state.authorMenuTemplate != "Poster" + bgImageLabel.Hidden = state.authorMenuTemplate != "Poster" + + menuTemplateSelect.OnChanged = func(value string) { + state.authorMenuTemplate = value + showPoster := value == "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.SetChecked(state.authorMenuLogoEnabled) + + logoLabel := widget.NewLabel(state.authorMenuLogoPath) + logoLabel.Wrapping = fyne.TextWrapWord + logoPickButton := widget.NewButton("Select Logo", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.authorMenuLogoPath = reader.URI().Path() + logoLabel.SetText(state.authorMenuLogoPath) + state.updateAuthorSummary() + state.persistAuthorConfig() + }, state.window) + }) + logoPickButton.Importance = widget.MediumImportance + + logoPositionSelect := widget.NewSelect([]string{ + "Top Left", + "Top Right", + "Bottom Left", + "Bottom Right", + "Center", + }, func(value string) { + state.authorMenuLogoPosition = value + state.persistAuthorConfig() + }) + if state.authorMenuLogoPosition == "" { + state.authorMenuLogoPosition = "Top Right" + } + logoPositionSelect.SetSelected(state.authorMenuLogoPosition) + + 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() + } + + if state.authorMenuLogoMargin == 0 { + state.authorMenuLogoMargin = 24 + } + marginLabel := widget.NewLabel(fmt.Sprintf("Logo Margin: %dpx", state.authorMenuLogoMargin)) + marginSlider := widget.NewSlider(0, 60) + marginSlider.Step = 2 + marginSlider.Value = float64(state.authorMenuLogoMargin) + marginSlider.OnChanged = func(v float64) { + state.authorMenuLogoMargin = int(math.Round(v)) + marginLabel.SetText(fmt.Sprintf("Logo Margin: %dpx", state.authorMenuLogoMargin)) + state.persistAuthorConfig() + } + + info := widget.NewLabel("DVD menus use 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(), + createMenuCheck, + widget.NewLabel("Theme:"), + menuThemeSelect, + widget.NewLabel("Template:"), + menuTemplateSelect, + bgImageLabel, + bgImageButton, + widget.NewSeparator(), + logoEnableCheck, + widget.NewLabel("Logo Path:"), + logoLabel, + logoPickButton, + widget.NewLabel("Logo Position:"), + logoPositionSelect, + scaleLabel, + scaleSlider, + marginLabel, + marginSlider, + widget.NewSeparator(), + info, + ) + + return container.NewPadded(controls) +} + func buildAuthorDiscTab(state *appState) fyne.CanvasObject { generateBtn := widget.NewButton("GENERATE DVD", func() { if len(state.authorClips) == 0 && state.authorFile == nil { @@ -1781,6 +1956,12 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu "additionalAudios": append([]string{}, s.authorAudioTracks...), "menuTemplate": s.authorMenuTemplate, "menuBackgroundImage": s.authorMenuBackgroundImage, + "menuTheme": s.authorMenuTheme, + "menuLogoEnabled": s.authorMenuLogoEnabled, + "menuLogoPath": s.authorMenuLogoPath, + "menuLogoPosition": s.authorMenuLogoPosition, + "menuLogoScale": s.authorMenuLogoScale, + "menuLogoMargin": s.authorMenuLogoMargin, } titleLabel := title @@ -1842,7 +2023,7 @@ func (s *appState) addAuthorVideoTSToQueue(videoTSPath, title, outputPath string return nil } -func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, region, aspect, title, outputPath string, makeISO bool, clips []authorClip, chapters []authorChapter, treatAsChapters bool, createMenu bool, menuTemplate string, menuBackgroundImage string, logFn func(string), progressFn func(float64)) error { +func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, region, aspect, title, outputPath string, makeISO bool, clips []authorClip, chapters []authorChapter, treatAsChapters bool, createMenu bool, menuTemplate string, menuBackgroundImage string, menuTheme string, menuLogoEnabled bool, menuLogoPath, menuLogoPosition string, menuLogoScale float64, menuLogoMargin int, logFn func(string), progressFn func(float64)) error { tempRoot := authorTempRoot(outputPath) if err := os.MkdirAll(tempRoot, 0755); err != nil { return fmt.Errorf("failed to create temp root: %w", err) @@ -2028,7 +2209,25 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg if !ok { template = &SimpleMenu{} } - menuMpg, menuButtons, err = buildDVDMenuAssets(ctx, workDir, title, region, aspect, chapters, logFn, template, menuBackgroundImage) + menuMpg, menuButtons, err = buildDVDMenuAssets( + ctx, + workDir, + title, + region, + aspect, + chapters, + logFn, + template, + menuBackgroundImage, + &menuTheme{Name: menuTheme}, + menuLogoOptions{ + Enabled: menuLogoEnabled, + Path: menuLogoPath, + Position: menuLogoPosition, + Scale: menuLogoScale, + Margin: menuLogoMargin, + }, + ) if err != nil { return err } @@ -2338,7 +2537,29 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres }, false) } - err := s.runAuthoringPipeline(ctx, paths, region, aspect, title, outputPath, makeISO, clips, chapters, treatAsChapters, createMenu, toString(cfg["menuTemplate"]), toString(cfg["menuBackgroundImage"]), appendLog, updateProgress) + err := s.runAuthoringPipeline( + ctx, + paths, + region, + aspect, + title, + outputPath, + makeISO, + clips, + chapters, + treatAsChapters, + createMenu, + toString(cfg["menuTemplate"]), + toString(cfg["menuBackgroundImage"]), + toString(cfg["menuTheme"]), + toBool(cfg["menuLogoEnabled"]), + toString(cfg["menuLogoPath"]), + toString(cfg["menuLogoPosition"]), + toFloat(cfg["menuLogoScale"]), + int(toFloat(cfg["menuLogoMargin"])), + appendLog, + updateProgress, + ) if err != nil { friendly := authorFriendlyError(err) appendLog("ERROR: " + friendly) diff --git a/internal/player/unified_ffmpeg_player.go b/internal/player/unified_ffmpeg_player.go index b058d2a..d25da15 100644 --- a/internal/player/unified_ffmpeg_player.go +++ b/internal/player/unified_ffmpeg_player.go @@ -47,6 +47,7 @@ type UnifiedPlayer struct { muted bool fullscreen bool previewMode bool + paused bool // Playback paused state // Video info videoInfo *VideoInfo @@ -136,12 +137,8 @@ func (p *UnifiedPlayer) Load(path string, offset time.Duration) error { } } - // TODO: wire up FFmpeg process startup and pipe handling. - p.state = StateStopped - if p.stateCallback != nil { - p.stateCallback(p.state) - } - return nil + // Start FFmpeg process for unified A/V output + return p.startVideoProcess() } // SeekToTime seeks to a specific time without restarting processes @@ -209,6 +206,19 @@ func (p *UnifiedPlayer) GetDuration() time.Duration { return p.duration } +// GetFrameImage reads and returns the current video frame as an RGBA image +// This is the main method for getting video frames to display in the UI +func (p *UnifiedPlayer) GetFrameImage() (*image.RGBA, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state != StatePlaying || p.paused { + return nil, nil + } + + return p.readVideoFrame() +} + // GetFrameRate returns the video frame rate func (p *UnifiedPlayer) GetFrameRate() float64 { p.mu.RLock() @@ -400,16 +410,115 @@ func (p *UnifiedPlayer) Stop() error { _ = p.cmd.Process.Kill() } p.state = StateStopped + p.paused = false if p.stateCallback != nil { p.stateCallback(p.state) } return nil } +// Play starts or resumes video playback +func (p *UnifiedPlayer) Play() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state == StateStopped { + // Need to load first + return fmt.Errorf("no video loaded") + } + + p.paused = false + p.state = StatePlaying + p.syncClock = time.Now() + + logging.Debug(logging.CatPlayer, "UnifiedPlayer: Play() called, state=%v", p.state) + + if p.stateCallback != nil { + p.stateCallback(p.state) + } + return nil +} + +// Pause pauses video playback +func (p *UnifiedPlayer) Pause() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state != StatePlaying { + return nil // Already paused or stopped + } + + p.paused = true + p.state = StatePaused + + logging.Debug(logging.CatPlayer, "UnifiedPlayer: Pause() called, state=%v", p.state) + + if p.stateCallback != nil { + p.stateCallback(p.state) + } + return nil +} + +// IsPaused returns whether playback is paused +func (p *UnifiedPlayer) IsPaused() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.paused +} + +// IsPlaying returns whether playback is active +func (p *UnifiedPlayer) IsPlaying() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.state == StatePlaying && !p.paused +} + // Helper methods -// startVideoProcess starts the video processing goroutine +// startVideoProcess starts the video processing goroutine and FFmpeg process func (p *UnifiedPlayer) startVideoProcess() error { + // Build FFmpeg command for unified A/V output + args := []string{ + "-hide_banner", "-loglevel", "error", + "-ss", fmt.Sprintf("%.3f", p.currentTime.Seconds()), + "-i", p.currentPath, + // Video stream to pipe 4 + "-map", "0:v:0", + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-r", "24", // We'll detect actual framerate + "pipe:4", + // Audio stream to pipe 5 + "-map", "0:a:0", + "-ac", "2", + "-ar", "48000", + "-f", "s16le", + "pipe:5", + } + + // Add hardware acceleration if available + if p.config.HardwareAccel { + if args = p.addHardwareAcceleration(args); args != nil { + logging.Debug(logging.CatPlayer, "Hardware acceleration enabled: %v", args) + } + } + + // Create FFmpeg command + cmd := utils.CreateCommandRaw(utils.GetFFmpegPath(), args...) + cmd.Stdin = nil + cmd.Stdout = p.videoPipeWriter + cmd.Stderr = nil // We'll handle errors through logging + + // Start FFmpeg process + if err := cmd.Start(); err != nil { + logging.Error(logging.CatPlayer, "Failed to start FFmpeg: %v", err) + return err + } + + // Store command reference + p.cmd = cmd + + // Start video frame reading goroutine go func() { frameDuration := time.Second / time.Duration(p.frameRate) frameTime := p.syncClock @@ -493,27 +602,51 @@ func (p *UnifiedPlayer) readAudioStream() { // readVideoStream reads video frames from the video pipe func (p *UnifiedPlayer) readVideoFrame() (*image.RGBA, error) { - // Read RGB24 frame data - frameSize := p.windowW * p.windowH * 3 // RGB24 = 3 bytes per pixel - frameData := make([]byte, frameSize) - n, err := p.videoPipeReader.Read(frameData) - - if err != nil && err.Error() != "EOF" { - return nil, fmt.Errorf("video read error: %w", err) - } - - if n == 0 { + // Check if paused - skip reading frames while paused + if p.paused { return nil, nil } - // Get frame from pool - img := p.frameBuffer.Get().(*image.RGBA) - img.Pix = make([]uint8, frameSize) - img.Stride = p.windowW * 3 - img.Rect = image.Rect(0, 0, p.windowW, p.windowH) + // Read RGB24 frame data from FFmpeg pipe + frameSize := p.windowW * p.windowH * 3 // RGB24 = 3 bytes per pixel + frameData := make([]byte, frameSize) - // Copy RGB data to image - copy(img.Pix, frameData[:frameSize]) + // Read full frame - io.ReadFull ensures we get the complete frame + n, err := io.ReadFull(p.videoPipeReader, frameData) + if err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil, nil // End of stream + } + return nil, fmt.Errorf("video read error: %w", err) + } + + if n != frameSize { + return nil, fmt.Errorf("incomplete frame: got %d bytes, expected %d", n, frameSize) + } + + // Create RGBA image (Fyne requires RGBA, not RGB) + img := image.NewRGBA(image.Rect(0, 0, p.windowW, p.windowH)) + + // Convert RGB24 to RGBA (add alpha channel) + for y := 0; y < p.windowH; y++ { + for x := 0; x < p.windowW; x++ { + srcIdx := (y*p.windowW + x) * 3 + dstIdx := (y*p.windowW + x) * 4 + + img.Pix[dstIdx+0] = frameData[srcIdx+0] // R + img.Pix[dstIdx+1] = frameData[srcIdx+1] // G + img.Pix[dstIdx+2] = frameData[srcIdx+2] // B + img.Pix[dstIdx+3] = 255 // A (fully opaque) + } + } + + // Update frame counter + p.currentFrame++ + + // Notify time callback + if p.timeCallback != nil { + p.timeCallback(p.currentTime) + } return img, nil } diff --git a/internal/player/unified_player_adapter.go b/internal/player/unified_player_adapter.go index 0eed0a9..07d7f16 100644 --- a/internal/player/unified_player_adapter.go +++ b/internal/player/unified_player_adapter.go @@ -113,7 +113,7 @@ func (p *UnifiedPlayerAdapter) Play() { } if p.paused { - // Start playback if not already started + // Load video if not already loaded if p.current == 0 { err := p.player.Load(p.path, 0) if err != nil { @@ -121,9 +121,15 @@ func (p *UnifiedPlayerAdapter) Play() { } } + // Start playback in UnifiedPlayer + if err := p.player.Play(); err != nil { + return + } + p.paused = false p.startTime = time.Now().Add(-time.Duration(p.current * float64(time.Second))) p.startUpdateLoop() + p.startFrameDisplayLoop() } } @@ -132,6 +138,9 @@ func (p *UnifiedPlayerAdapter) Pause() { p.mu.Lock() defer p.mu.Unlock() + if p.player != nil { + p.player.Pause() + } p.paused = true p.stopUpdateLoop() } @@ -304,6 +313,39 @@ func (p *UnifiedPlayerAdapter) stopUpdateLoop() { } } +// startFrameDisplayLoop starts the loop that reads frames and displays them +func (p *UnifiedPlayerAdapter) startFrameDisplayLoop() { + if p.player == nil || p.img == nil { + return + } + + go func() { + // Display at frame rate + frameDuration := time.Second / time.Duration(p.fps) + ticker := time.NewTicker(frameDuration) + defer ticker.Stop() + + for { + select { + case <-p.stop: + return + case <-ticker.C: + p.mu.Lock() + if !p.paused && p.player != nil { + // Get frame from UnifiedPlayer + frame, err := p.player.GetFrameImage() + if err == nil && frame != nil { + // Update the Fyne canvas image + p.img.Image = frame + p.img.Refresh() + } + } + p.mu.Unlock() + } + } + }() +} + // GetVideoFrame returns the current video frame for display func (p *UnifiedPlayerAdapter) GetVideoFrame() *image.RGBA { p.mu.Lock() @@ -313,16 +355,18 @@ func (p *UnifiedPlayerAdapter) GetVideoFrame() *image.RGBA { return nil } - // Create a placeholder frame for now - // In full implementation, this would get frame from UnifiedPlayer - rect := image.Rect(0, 0, p.targetW, p.targetH) - frame := image.NewRGBA(rect) - - // Fill with black background - for y := 0; y < p.targetH; y++ { - for x := 0; x < p.targetW; x++ { - frame.SetRGBA(x, y, color.RGBA{0, 0, 0, 255}) + // Get real frame from UnifiedPlayer + frame, err := p.player.GetFrameImage() + if err != nil || frame == nil { + // Return black frame on error + rect := image.Rect(0, 0, p.targetW, p.targetH) + blackFrame := image.NewRGBA(rect) + for y := 0; y < p.targetH; y++ { + for x := 0; x < p.targetW; x++ { + blackFrame.SetRGBA(x, y, color.RGBA{0, 0, 0, 255}) + } } + return blackFrame } return frame diff --git a/main.go b/main.go index 18d87c0..462965f 100644 --- a/main.go +++ b/main.go @@ -597,6 +597,17 @@ func (s *appState) showAbout() { copyRow := container.NewBorder(nil, nil, nil, copyBtn, btcLabel) addressLabel := widget.NewLabel(btcAddress) + // X (Twitter) account + xURL := "https://x.com/VT_VideoTools" + xLabel := widget.NewLabel("X: @VT_VideoTools") + xBtn := widget.NewButton("Open", func() { + if err := openURL(xURL); err != nil { + dialog.ShowError(fmt.Errorf("failed to open X profile: %w", err), s.window) + } + }) + xBtn.Importance = widget.LowImportance + xRow := container.NewHBox(xLabel, xBtn) + mainContent := container.NewVBox( versionText, devText, @@ -604,6 +615,7 @@ func (s *appState) showAbout() { widget.NewLabel("Support Development"), copyRow, addressLabel, + xRow, feedbackLabel, ) @@ -1128,6 +1140,12 @@ type appState struct { authorCreateMenu bool // Whether to create DVD menu authorMenuTemplate string // "Simple", "Dark", "Poster" authorMenuBackgroundImage string // Path to a user-selected background image + authorMenuTheme string // "VideoTools" + authorMenuLogoEnabled bool + authorMenuLogoPath string // Path to menu logo image + authorMenuLogoPosition string // "Top Left", "Top Right", "Bottom Left", "Bottom Right", "Center" + authorMenuLogoScale float64 + authorMenuLogoMargin int authorTitle string // DVD title authorSubtitles []string // Subtitle file paths authorAudioTracks []string // Additional audio tracks