VideoTools/settings_module.go

576 lines
17 KiB
Go

package main
import (
"context"
"fmt"
"image/color"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
// Dependency represents a system dependency
type Dependency struct {
Name string
Command string // Command to check if installed
Required bool // If true, core functionality requires this
Description string
InstallCmd string // Command to install (platform-specific)
UninstallCmd string // Command to uninstall (platform-specific, optional)
}
// dependencyCommand represents a command with optional arguments
// command must be non-empty; args may be empty
type dependencyCommand struct {
command string
args []string
}
// dependencyCommandPair holds install/uninstall commands
// nil entries mean unavailable for current platform
type dependencyCommandPair struct {
install *dependencyCommand
uninstall *dependencyCommand
}
func projectRoot() string {
if exe, err := os.Executable(); err == nil {
if dir := filepath.Dir(exe); dir != "" {
return dir
}
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
func detectPkgManager() string {
managers := []string{"apt-get", "dnf", "pacman", "zypper"}
for _, m := range managers {
if _, err := exec.LookPath(m); err == nil {
return m
}
}
return ""
}
func pkgManagerInstall(pkg string) *dependencyCommand {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("brew"); err == nil {
return &dependencyCommand{command: "brew", args: []string{"install", pkg}}
}
case "linux":
switch detectPkgManager() {
case "apt-get":
return &dependencyCommand{command: "sudo", args: []string{"apt-get", "install", "-y", pkg}}
case "dnf":
return &dependencyCommand{command: "sudo", args: []string{"dnf", "install", "-y", pkg}}
case "pacman":
return &dependencyCommand{command: "sudo", args: []string{"pacman", "-S", "--needed", "--noconfirm", pkg}}
case "zypper":
return &dependencyCommand{command: "sudo", args: []string{"zypper", "install", "-y", pkg}}
}
case "windows":
if _, err := exec.LookPath("choco"); err == nil {
return &dependencyCommand{command: "powershell", args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", fmt.Sprintf("choco install -y %s", pkg)}}
}
}
return nil
}
func pkgManagerUninstall(pkg string) *dependencyCommand {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("brew"); err == nil {
return &dependencyCommand{command: "brew", args: []string{"uninstall", pkg}}
}
case "linux":
switch detectPkgManager() {
case "apt-get":
return &dependencyCommand{command: "sudo", args: []string{"apt-get", "remove", "-y", pkg}}
case "dnf":
return &dependencyCommand{command: "sudo", args: []string{"dnf", "remove", "-y", pkg}}
case "pacman":
return &dependencyCommand{command: "sudo", args: []string{"pacman", "-Rns", "--noconfirm", pkg}}
case "zypper":
return &dependencyCommand{command: "sudo", args: []string{"zypper", "remove", "-y", pkg}}
}
case "windows":
if _, err := exec.LookPath("choco"); err == nil {
return &dependencyCommand{command: "powershell", args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", fmt.Sprintf("choco uninstall -y %s", pkg)}}
}
}
return nil
}
func getDependencyCommands(depName string) dependencyCommandPair {
root := projectRoot()
switch depName {
case "dvdauthor":
// Windows: reuse installer to pull DVDStyler tools; skip ffmpeg/gst to keep scope smaller
if runtime.GOOS == "windows" {
script := filepath.Join(root, "scripts", "install-deps-windows.ps1")
return dependencyCommandPair{
install: &dependencyCommand{
command: "powershell",
args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-SkipFFmpeg:$true", "-SkipGStreamer:$true", "-SkipDvdStyler:$false"},
},
}
}
return dependencyCommandPair{
install: pkgManagerInstall("dvdauthor"),
uninstall: pkgManagerUninstall("dvdauthor"),
}
case "xorriso":
if runtime.GOOS == "windows" {
script := filepath.Join(root, "scripts", "install-deps-windows.ps1")
return dependencyCommandPair{
install: &dependencyCommand{
command: "powershell",
args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-SkipFFmpeg:$true", "-SkipGStreamer:$true", "-SkipDvdStyler:$false"},
},
}
}
return dependencyCommandPair{
install: pkgManagerInstall("xorriso"),
uninstall: pkgManagerUninstall("xorriso"),
}
case "realesrgan-ncnn-vulkan":
// Best-effort: invoke existing installer with AI enabled
installScript := filepath.Join(root, "scripts", "install.sh")
switch runtime.GOOS {
case "linux", "darwin":
return dependencyCommandPair{
install: &dependencyCommand{command: "bash", args: []string{installScript, "--skip-ai=false", "--skip-dvd", "--skip-whisper"}},
}
case "windows":
// Not readily available via package manager; fall back to warning
return dependencyCommandPair{}
}
case "whisper":
if runtime.GOOS == "windows" {
return dependencyCommandPair{
install: &dependencyCommand{command: "powershell", args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "py -m pip install --user openai-whisper"}},
uninstall: &dependencyCommand{command: "powershell", args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "py -m pip uninstall -y openai-whisper"}},
}
}
return dependencyCommandPair{
install: &dependencyCommand{command: "python3", args: []string{"-m", "pip", "install", "--user", "openai-whisper"}},
uninstall: &dependencyCommand{command: "python3", args: []string{"-m", "pip", "uninstall", "-y", "openai-whisper"}},
}
}
return dependencyCommandPair{}
}
func runDependencyCommandWithProgress(win fyne.Window, title, message string, depCmd *dependencyCommand, onDone func(output string, err error)) {
if depCmd == nil {
dialog.ShowError(fmt.Errorf("no command available for this platform"), win)
return
}
progress := dialog.NewProgressInfinite(title, message, win)
progress.Show()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
cmd := utils.CreateCommand(ctx, depCmd.command, depCmd.args...)
cmd.Dir = projectRoot()
output, err := cmd.CombinedOutput()
trimmed := strings.TrimSpace(string(output))
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
progress.Hide()
onDone(trimmed, err)
}, false)
}()
}
func showCommandResult(win fyne.Window, title string, output string, err error) {
const maxLen = 2000
if len(output) > maxLen {
output = output[:maxLen] + "..."
}
if err != nil {
dialog.ShowError(fmt.Errorf("command failed: %w\n%s", err, output), win)
return
}
if output == "" {
dialog.ShowInformation(title, "Completed successfully.", win)
return
}
dialog.ShowInformation(title, output, win)
}
// ModuleDependencies maps module IDs to their required dependencies
var moduleDependencies = map[string][]string{
"convert": {"ffmpeg"},
"merge": {"ffmpeg"},
"trim": {"ffmpeg"},
"filters": {"ffmpeg"},
"upscale": {"ffmpeg"}, // realesrgan-ncnn-vulkan is optional for AI upscaling
"audio": {"ffmpeg"},
"author": {"ffmpeg", "dvdauthor", "xorriso"},
"rip": {"ffmpeg", "xorriso"},
"bluray": {"ffmpeg"},
"subtitles": {"ffmpeg", "whisper"},
"thumbnail": {"ffmpeg"},
"compare": {"ffmpeg"},
"inspect": {"ffmpeg"},
"player": {"ffmpeg"},
}
// AllDependencies defines all possible dependencies
var allDependencies = map[string]Dependency{
"ffmpeg": {
Name: "FFmpeg",
Command: "ffmpeg",
Required: true,
Description: "Core video processing engine",
InstallCmd: getFFmpegInstallCmd(),
},
"dvdauthor": {
Name: "DVDAuthor",
Command: "dvdauthor",
Required: false,
Description: "DVD authoring tool",
InstallCmd: getDVDAuthorInstallCmd(),
},
"xorriso": {
Name: "xorriso",
Command: "xorriso",
Required: false,
Description: "ISO creation and extraction",
InstallCmd: getXorrisoInstallCmd(),
},
"realesrgan-ncnn-vulkan": {
Name: "Real-ESRGAN",
Command: "realesrgan-ncnn-vulkan",
Required: false,
Description: "AI video upscaling",
InstallCmd: "See install.sh --skip-ai=false",
},
"whisper": {
Name: "Whisper",
Command: "whisper",
Required: false,
Description: "AI subtitle generation",
InstallCmd: "pip3 install --user openai-whisper",
},
}
func getFFmpegInstallCmd() string {
switch runtime.GOOS {
case "linux":
return "sudo apt-get install ffmpeg # or dnf/pacman/zypper"
case "darwin":
return "brew install ffmpeg"
case "windows":
return "Download from ffmpeg.org"
default:
return "See ffmpeg.org for installation"
}
}
func getDVDAuthorInstallCmd() string {
switch runtime.GOOS {
case "linux":
return "sudo apt-get install dvdauthor # or dnf/pacman/zypper"
case "darwin":
return "brew install dvdauthor"
default:
return "./scripts/install.sh"
}
}
func getXorrisoInstallCmd() string {
switch runtime.GOOS {
case "linux":
return "sudo apt-get install xorriso # or dnf/pacman/zypper"
case "darwin":
return "brew install xorriso"
default:
return "./scripts/install.sh"
}
}
// checkDependency checks if a command is available
func checkDependency(command string) bool {
_, err := exec.LookPath(command)
return err == nil
}
// getModuleDependencyStatus checks which dependencies a module is missing
func getModuleDependencyStatus(moduleID string) (missing []string, hasAll bool) {
deps, ok := moduleDependencies[moduleID]
if !ok {
return nil, true // Module has no dependencies
}
for _, depName := range deps {
dep, exists := allDependencies[depName]
if !exists {
continue
}
if !checkDependency(dep.Command) {
missing = append(missing, depName)
}
}
return missing, len(missing) == 0
}
// isModuleAvailable returns true if all required dependencies are installed
func isModuleAvailable(moduleID string) bool {
_, hasAll := getModuleDependencyStatus(moduleID)
return hasAll
}
func buildSettingsView(state *appState) fyne.CanvasObject {
settingsColor := utils.MustHex("#607D8B") // Blue Grey for settings
backBtn := widget.NewButton("< BACK", func() {
state.showMainMenu()
})
backBtn.Importance = widget.LowImportance
topBar := ui.TintedBar(settingsColor, container.NewHBox(backBtn, layout.NewSpacer()))
bottomBar := moduleFooter(settingsColor, layout.NewSpacer(), state.statsBar)
tabs := container.NewAppTabs(
container.NewTabItem("Dependencies", buildDependenciesTab(state)),
container.NewTabItem("Benchmark", buildBenchmarkTab(state)),
container.NewTabItem("Preferences", buildPreferencesTab(state)),
)
tabs.SetTabLocation(container.TabLocationTop)
// Single fast scroll container for entire tabs area (12x speed)
scrollableTabs := ui.NewFastVScroll(tabs)
return container.NewBorder(topBar, bottomBar, nil, nil, scrollableTabs)
}
func buildDependenciesTab(state *appState) fyne.CanvasObject {
content := container.NewVBox()
// Header
header := widget.NewLabel("System Dependencies")
header.TextStyle = fyne.TextStyle{Bold: true}
content.Add(header)
desc := widget.NewLabel("Manage VideoTools dependencies. Some modules require specific tools to be installed.")
desc.Wrapping = fyne.TextWrapWord
content.Add(desc)
content.Add(widget.NewSeparator())
// Check all dependencies
for depName, dep := range allDependencies {
isInstalled := checkDependency(dep.Command)
nameLabel := widget.NewLabel(dep.Name)
nameLabel.TextStyle = fyne.TextStyle{Bold: true}
statusLabel := widget.NewLabel("")
if isInstalled {
statusLabel.SetText("✓ Installed")
statusLabel.TextStyle = fyne.TextStyle{Italic: true}
} else {
statusLabel.SetText("✗ Not Installed")
statusLabel.TextStyle = fyne.TextStyle{Italic: true}
}
descLabel := widget.NewLabel(dep.Description)
descLabel.TextStyle = fyne.TextStyle{Italic: true}
descLabel.Wrapping = fyne.TextWrapWord
installLabel := widget.NewLabel(dep.InstallCmd)
installLabel.Wrapping = fyne.TextWrapWord
var statusColor color.Color
if isInstalled {
statusColor = utils.MustHex("#4CAF50") // Green
} else {
statusColor = utils.MustHex("#F44336") // Red
}
statusBg := canvas.NewRectangle(statusColor)
statusBg.CornerRadius = 3
statusRow := container.NewHBox(statusBg, statusLabel)
actions := container.NewHBox()
cmds := getDependencyCommands(depName)
if cmds.install != nil {
installBtn := widget.NewButton("Install", func() {
runDependencyCommandWithProgress(state.window, fmt.Sprintf("Installing %s", dep.Name), dep.InstallCmd, cmds.install, func(out string, err error) {
showCommandResult(state.window, fmt.Sprintf("%s Install", dep.Name), out, err)
state.showSettingsView()
})
})
installBtn.Importance = widget.HighImportance
if isInstalled {
installBtn.Disable()
}
actions.Add(installBtn)
}
if cmds.uninstall != nil {
uninstallBtn := widget.NewButton("Uninstall", func() {
dialog.ShowConfirm(fmt.Sprintf("Uninstall %s?", dep.Name), "This will attempt to remove the dependency using your package manager.", func(ok bool) {
if !ok {
return
}
runDependencyCommandWithProgress(state.window, fmt.Sprintf("Uninstalling %s", dep.Name), dep.InstallCmd, cmds.uninstall, func(out string, err error) {
showCommandResult(state.window, fmt.Sprintf("%s Uninstall", dep.Name), out, err)
state.showSettingsView()
})
}, state.window)
})
uninstallBtn.Importance = widget.LowImportance
if !isInstalled {
uninstallBtn.Disable()
}
actions.Add(uninstallBtn)
}
infoBox := container.NewVBox(
container.NewHBox(nameLabel, layout.NewSpacer(), statusRow),
descLabel,
)
if !isInstalled {
installCmdLabel := widget.NewLabel("Install: " + installLabel.Text)
installCmdLabel.Wrapping = fyne.TextWrapWord
infoBox.Add(installCmdLabel)
}
if actions.Objects != nil && len(actions.Objects) > 0 {
actionsContainer := container.NewHBox(actions.Objects...)
infoBox.Add(actionsContainer)
}
// Check which modules need this dependency
modulesNeeding := []string{}
for modID, deps := range moduleDependencies {
for _, d := range deps {
if d == depName {
// Find module name
for _, m := range modulesList {
if m.ID == modID {
modulesNeeding = append(modulesNeeding, m.Label)
break
}
}
break
}
}
}
if len(modulesNeeding) > 0 {
neededLabel := widget.NewLabel("Required by: " + strings.Join(modulesNeeding, ", "))
neededLabel.TextStyle = fyne.TextStyle{Italic: true}
neededLabel.Wrapping = fyne.TextWrapWord
infoBox.Add(neededLabel)
}
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
cardBg.CornerRadius = 6
card := container.NewPadded(container.NewMax(cardBg, infoBox))
content.Add(card)
}
// Refresh button
content.Add(widget.NewSeparator())
refreshBtn := widget.NewButton("Refresh Status", func() {
state.showSettingsView()
})
content.Add(refreshBtn)
return content
}
func buildBenchmarkTab(state *appState) fyne.CanvasObject {
content := container.NewVBox()
// Header
header := widget.NewLabel("Hardware Benchmark")
header.TextStyle = fyne.TextStyle{Bold: true}
content.Add(header)
desc := widget.NewLabel("Test your system's video encoding performance to get optimal encoder recommendations.")
desc.Wrapping = fyne.TextWrapWord
content.Add(desc)
content.Add(widget.NewSeparator())
// Run benchmark button
runBtn := widget.NewButton("Run Hardware Benchmark", func() {
state.showBenchmark()
})
runBtn.Importance = widget.MediumImportance
content.Add(container.NewCenter(runBtn))
// Show recent results if available
cfg, err := loadBenchmarkConfig()
if err == nil && len(cfg.History) > 0 {
content.Add(widget.NewSeparator())
recentHeader := widget.NewLabel("Recent Benchmarks")
recentHeader.TextStyle = fyne.TextStyle{Bold: true}
content.Add(recentHeader)
for _, run := range cfg.History[:min(3, len(cfg.History))] {
timestamp := run.Timestamp.Format("Jan 2, 2006 at 3:04 PM")
summary := fmt.Sprintf("%s - Recommended: %s (%s)",
timestamp, run.RecommendedEncoder, run.RecommendedPreset)
runLabel := widget.NewLabel(summary)
runLabel.TextStyle = fyne.TextStyle{Italic: true}
content.Add(runLabel)
}
}
return content
}
func buildPreferencesTab(state *appState) fyne.CanvasObject {
content := container.NewVBox()
header := widget.NewLabel("Application Preferences")
header.TextStyle = fyne.TextStyle{Bold: true}
content.Add(header)
content.Add(widget.NewLabel("Preferences panel - Coming soon"))
content.Add(widget.NewLabel("This will include settings for:"))
content.Add(widget.NewLabel("• Default output directories"))
content.Add(widget.NewLabel("• Default encoding presets"))
content.Add(widget.NewLabel("• UI theme preferences"))
content.Add(widget.NewLabel("• Automatic updates"))
return content
}
func (s *appState) showSettingsView() {
s.stopPreview()
s.lastModule = s.active
s.active = "settings"
s.setContent(buildSettingsView(s))
}