package hotkeys import ( "fmt" "os" "path/filepath" "strings" "time" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) // inputConfFileName is the mpv input configuration file name. const inputConfFileName = "input.conf" // inputConfBackupPrefix is used when creating timestamped backups. const inputConfBackupPrefix = "-input.conf.bak" // Binding represents a single parsed line from an input.conf file. type Binding struct { Key string // Key combination (e.g. "Ctrl+RIGHT", "SPACE") Command string // mpv command (e.g. "seek 5", "cycle pause") Comment string // Inline comment text after the command (without "#") IsComment bool // True if the line is a pure comment line IsSection bool // True if the line is a section header like [default] IsEmpty bool // True if the line is blank RawLine string // Original line text for round-trip preservation } // InputConfig holds the parsed representation of an input.conf file. type InputConfig struct { Bindings []Binding FilePath string } // PresetInfo describes an available hotkey preset profile. type PresetInfo struct { ID string // Machine identifier: "default", "vlc", "mpc-hc" Name string // Display name: "mpv Default", "VLC", "MPC-HC" Description string // Short description of the preset } // ParseInputConf parses input.conf content into structured bindings. // // The input.conf format is: // // KEY COMMAND [# comment] // # comment line // [section-name] // (blank lines) // // Each binding line has a key and at least one command word, optionally followed // by a "#" and inline comment. Comment lines, section headers, and blank lines are // preserved for round-trip fidelity. func ParseInputConf(content string) (*InputConfig, error) { config := &InputConfig{ Bindings: []Binding{}, } lines := strings.Split(content, "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) // Blank line if trimmed == "" { config.Bindings = append(config.Bindings, Binding{ IsEmpty: true, RawLine: line, }) continue } // Section header (e.g. [default]) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { config.Bindings = append(config.Bindings, Binding{ IsSection: true, RawLine: line, }) continue } // Pure comment line if strings.HasPrefix(trimmed, "#") { config.Bindings = append(config.Bindings, Binding{ IsComment: true, Comment: strings.TrimPrefix(trimmed, "#"), RawLine: line, }) continue } // Binding line: KEY COMMAND [# comment] binding := parseBindingLine(line) config.Bindings = append(config.Bindings, binding) } return config, nil } // parseBindingLine parses a single binding line into a Binding struct. func parseBindingLine(line string) Binding { binding := Binding{ RawLine: line, } trimmed := strings.TrimSpace(line) // Split off inline comment if present. We look for " #" to avoid splitting // inside quoted command arguments that contain "#". var commentPart string if idx := strings.Index(trimmed, " #"); idx != -1 { commentPart = trimmed[idx+2:] // text after "# " trimmed = trimmed[:idx] } // First field is the key, the rest is the command fields := strings.Fields(trimmed) if len(fields) == 0 { binding.IsEmpty = true return binding } binding.Key = fields[0] if len(fields) > 1 { binding.Command = strings.Join(fields[1:], " ") } binding.Comment = commentPart return binding } // ReadInputConf reads and parses the input.conf file from disk. // If the file does not exist it returns an empty InputConfig (no error). func ReadInputConf(path string) (*InputConfig, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return &InputConfig{ Bindings: []Binding{}, FilePath: path, }, nil } return nil, fmt.Errorf("failed to read input.conf: %w", err) } config, err := ParseInputConf(string(data)) if err != nil { return nil, err } config.FilePath = path return config, nil } // WriteInputConf writes an InputConfig back to disk using an atomic write pattern. // A timestamped backup of any existing file is created before overwriting. func WriteInputConf(config *InputConfig, path string) error { // Create backup of existing file if it exists if _, err := os.Stat(path); err == nil { if backupErr := createInputConfBackup(path); backupErr != nil { log.Warn(fmt.Sprintf("Failed to create input.conf backup: %v", backupErr)) // Continue writing even if backup fails } } content := SerializeInputConf(config) return writeFileAtomically(path, []byte(content)) } // SerializeInputConf converts an InputConfig back to its text representation. func SerializeInputConf(config *InputConfig) string { var lines []string for _, b := range config.Bindings { lines = append(lines, serializeBinding(b)) } return strings.Join(lines, "\n") } // serializeBinding converts a single Binding back to its text form. func serializeBinding(b Binding) string { if b.IsEmpty { return "" } if b.IsComment { return "#" + b.Comment } if b.IsSection { return b.RawLine } // Regular binding var sb strings.Builder sb.WriteString(b.Key) if b.Command != "" { sb.WriteString(" ") sb.WriteString(b.Command) } if b.Comment != "" { sb.WriteString(" #") sb.WriteString(b.Comment) } return sb.String() } // GetInputConfPath returns the platform-aware path to the user's input.conf. // It lives in the same directory as mpv.conf. func GetInputConfPath() string { configPath, err := constants.GetMPVConfigPathWithHome() if err != nil { log.Warn(fmt.Sprintf("Failed to get MPV config path: %v", err)) return "" } dir := filepath.Dir(configPath) return filepath.Join(dir, inputConfFileName) } // LoadPreset loads a preset's raw content from the embedded assets. // Valid preset names are "default", "vlc", and "mpc-hc". func LoadPreset(presetName string) (string, error) { data, err := assets.ReadHotkeyPreset(presetName) if err != nil { return "", fmt.Errorf("unknown hotkey preset %q: %w", presetName, err) } return string(data), nil } // ApplyPreset loads the named preset and writes it to configPath atomically. // A backup of any existing input.conf at configPath is created first. func ApplyPreset(presetName string, configPath string) error { content, err := LoadPreset(presetName) if err != nil { return err } // Ensure the parent directory exists configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, constants.DirPermission); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } // Create backup if file exists if _, err := os.Stat(configPath); err == nil { if backupErr := createInputConfBackup(configPath); backupErr != nil { log.Warn(fmt.Sprintf("Failed to backup existing input.conf: %v", backupErr)) } } return writeFileAtomically(configPath, []byte(content)) } // GetPresetList returns information about all available preset profiles. func GetPresetList() []PresetInfo { return []PresetInfo{ { ID: "default", Name: "mpv Default", Description: "Standard mpv keybindings as shipped upstream", }, { ID: "vlc", Name: "VLC", Description: "Keybindings modeled after VLC media player", }, { ID: "mpc-hc", Name: "MPC-HC", Description: "Keybindings modeled after Media Player Classic Home Cinema", }, } } // GetActiveBindings returns the parsed bindings from the user's actual input.conf. // If the file doesn't exist, it returns an empty slice (no error) — mpv would use // its built-in defaults in that case. func GetActiveBindings() ([]Binding, error) { path := GetInputConfPath() if path == "" { return nil, nil } config, err := ReadInputConf(path) if err != nil { return nil, err } // Filter out non-binding lines for callers that only want actual key mappings var bindings []Binding for _, b := range config.Bindings { if b.Key != "" && b.Command != "" && !b.IsComment && !b.IsEmpty && !b.IsSection { bindings = append(bindings, b) } } return bindings, nil } // FindBinding returns the binding for a given key, or nil if not found. func FindBinding(config *InputConfig, key string) *Binding { for i := range config.Bindings { if config.Bindings[i].Key == key && !config.Bindings[i].IsComment && !config.Bindings[i].IsEmpty { return &config.Bindings[i] } } return nil } // SetBinding adds or updates a key binding in the config. // If the key already exists the command and comment are replaced; // otherwise the binding is appended. func SetBinding(config *InputConfig, key, command, comment string) { for i := range config.Bindings { if config.Bindings[i].Key == key && !config.Bindings[i].IsComment && !config.Bindings[i].IsEmpty { config.Bindings[i].Command = command config.Bindings[i].Comment = comment return } } config.Bindings = append(config.Bindings, Binding{ Key: key, Command: command, Comment: comment, }) } // RemoveBinding removes a key binding from the config. // Returns true if the binding was found and removed. func RemoveBinding(config *InputConfig, key string) bool { for i := range config.Bindings { if config.Bindings[i].Key == key && !config.Bindings[i].IsComment && !config.Bindings[i].IsEmpty { config.Bindings = append(config.Bindings[:i], config.Bindings[i+1:]...) return true } } return false } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // writeFileAtomically writes content to a file using an atomic rename pattern. func writeFileAtomically(path string, content []byte) error { tempPath := path + ".tmp" if err := os.WriteFile(tempPath, content, constants.FilePermission); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } if err := os.Rename(tempPath, path); err != nil { _ = os.Remove(tempPath) return fmt.Errorf("failed to rename temp file: %w", err) } return nil } // createInputConfBackup creates a timestamped backup copy of the given input.conf file. func createInputConfBackup(filePath string) error { timestamp := time.Now().Format("2006-01-02_150405") backupPath := filePath + inputConfBackupPrefix + "." + timestamp data, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read file for backup: %w", err) } if err := os.WriteFile(backupPath, data, constants.FilePermission); err != nil { return fmt.Errorf("failed to write backup file: %w", err) } log.Info(fmt.Sprintf("Created input.conf backup: %s", backupPath)) return nil }