Add DVD menu tab with theme and logo controls

This commit is contained in:
Stu Leak 2026-01-06 21:04:58 -05:00
parent 6ab73b859f
commit 46d8bd0f93
5 changed files with 717 additions and 182 deletions

View File

@ -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, ":", "\\:")

View File

@ -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)

View File

@ -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
}

View File

@ -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

18
main.go
View File

@ -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