package hotkeys import ( "errors" "fmt" "os" "path/filepath" "strings" "time" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/internal/confline" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/config" "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" const maxInputConfBackups = 20 // ErrBindingConflict is returned by SetBindingFile when overwrite is false // and the requested key already has a binding. var ErrBindingConflict = errors.New("key binding already exists") // 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 lineEnding string } // InputConfig holds the parsed representation of an input.conf file. type InputConfig struct { Bindings []Binding FilePath string newline string hasBOM bool } // 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{}, newline: "\n", } if strings.HasPrefix(content, "\ufeff") { config.hasBOM = true content = strings.TrimPrefix(content, "\ufeff") } if strings.Contains(content, "\r\n") { config.newline = "\r\n" } lines := strings.Split(content, "\n") for index, line := range lines { ending := "" if index < len(lines)-1 { ending = "\n" if strings.HasSuffix(line, "\r") { line = strings.TrimSuffix(line, "\r") ending = "\r\n" } } trimmed := strings.TrimSpace(line) // Blank line if trimmed == "" { config.Bindings = append(config.Bindings, Binding{ IsEmpty: true, RawLine: line, lineEnding: ending, }) continue } // Section header (e.g. [default]) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { config.Bindings = append(config.Bindings, Binding{ IsSection: true, RawLine: line, lineEnding: ending, }) continue } // Pure comment line if strings.HasPrefix(trimmed, "#") { config.Bindings = append(config.Bindings, Binding{ IsComment: true, Comment: strings.TrimPrefix(trimmed, "#"), RawLine: line, lineEnding: ending, }) continue } // Binding line: KEY COMMAND [# comment] binding := parseBindingLine(line) binding.lineEnding = ending 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 := confline.CommentIndex(trimmed, true); idx != -1 { commentPart = trimmed[idx+1:] // text after "#" trimmed = trimmed[:idx] } // First field is the key, the rest is the command trimmed = strings.TrimSpace(trimmed) if trimmed == "" { binding.IsEmpty = true return binding } if separator := strings.IndexAny(trimmed, " \t"); separator >= 0 { binding.Key = trimmed[:separator] binding.Command = strings.TrimSpace(trimmed[separator:]) } else { binding.Key = trimmed } 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 } func writeInputConfLocked(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 { return fmt.Errorf("failed to create input.conf backup: %w", backupErr) } } if err := os.MkdirAll(filepath.Dir(path), constants.DirPermission); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } content := SerializeInputConf(config) return writeFileAtomically(path, []byte(content)) } // SerializeInputConf converts an InputConfig back to its text representation. func SerializeInputConf(config *InputConfig) string { newline := config.newline if newline == "" { newline = "\n" } var content strings.Builder if config.hasBOM { content.WriteString("\ufeff") } for index, b := range config.Bindings { content.WriteString(serializeBinding(b)) ending := b.lineEnding if ending == "" && index < len(config.Bindings)-1 { ending = newline } content.WriteString(ending) } return content.String() } // serializeBinding converts a single Binding back to its text form. func serializeBinding(b Binding) string { // Raw text is authoritative while its parsed fields remain unchanged. // Comparing fields also supports callers that edit the exported Binding // fields directly rather than going through SetBinding. trimmed := strings.TrimSpace(b.RawLine) if b.IsEmpty && trimmed == "" { return b.RawLine } if b.IsComment && strings.HasPrefix(trimmed, "#") && strings.TrimPrefix(trimmed, "#") == b.Comment { return b.RawLine } if !b.IsEmpty && !b.IsComment && !b.IsSection && b.RawLine != "" { original := parseBindingLine(b.RawLine) if original.Key == b.Key && original.Command == b.Command && original.Comment == b.Comment { return b.RawLine } } 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 := config.GetMPVConfigFilePath() 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 } return fileops.WithLock(configPath, func() error { configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, constants.DirPermission); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } if _, err := os.Stat(configPath); err == nil { if backupErr := createInputConfBackup(configPath); backupErr != nil { return fmt.Errorf("failed to backup existing input.conf: %w", 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. Key, command, and comment are // validated first; on invalid input a descriptive error is returned and // the config is left unchanged. func SetBinding(config *InputConfig, key, command, comment string) error { if err := ValidateKeyName(key); err != nil { return err } if err := ValidateBindingCommand(command); err != nil { return err } if err := validateComment(comment); err != nil { return err } updated := false 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 updated = true } } if updated { return nil } config.Bindings = append(config.Bindings, Binding{ Key: key, Command: command, Comment: comment, }) return nil } // RemoveBinding removes a key binding from the config. // Returns true if the binding was found and removed. func RemoveBinding(config *InputConfig, key string) bool { removed := false kept := config.Bindings[:0] for _, binding := range config.Bindings { if binding.Key == key && !binding.IsComment && !binding.IsEmpty { removed = true continue } kept = append(kept, binding) } config.Bindings = kept return removed } // SetBindingFile validates and applies one binding to a fresh input.conf // snapshot while holding the path lock through backup and atomic replacement. // The existing binding is returned when overwrite is false so callers can // present a conflict confirmation without racing the commit. func SetBindingFile(path, key, command, comment string, overwrite bool) (*Binding, error) { if err := ValidateKeyName(key); err != nil { return nil, err } if err := ValidateBindingCommand(command); err != nil { return nil, err } if err := validateComment(comment); err != nil { return nil, err } var conflict *Binding err := fileops.WithLock(path, func() error { config, err := ReadInputConf(path) if err != nil { return err } if existing := FindBinding(config, key); existing != nil && !overwrite { copy := *existing conflict = © return ErrBindingConflict } if err := SetBinding(config, key, command, comment); err != nil { return err } return writeInputConfLocked(config, path) }) return conflict, err } // RemoveBindingFile removes one binding from a fresh input.conf snapshot while // holding the path lock through backup and atomic replacement. func RemoveBindingFile(path, key string) (bool, error) { if err := ValidateKeyName(key); err != nil { return false, err } removed := false err := fileops.WithLock(path, func() error { config, err := ReadInputConf(path) if err != nil { return err } removed = RemoveBinding(config, key) if !removed { return nil } return writeInputConfLocked(config, path) }) return removed, err } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // writeFileAtomically writes content to a file using an atomic rename pattern. func writeFileAtomically(path string, content []byte) error { return fileops.AtomicWritePreserve(path, content, constants.FilePermission) } // createInputConfBackup creates a timestamped backup copy of the given input.conf file. func createInputConfBackup(filePath string) error { info, err := os.Lstat(filePath) if err != nil { return fmt.Errorf("failed to inspect file for backup: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to back up non-regular file %s", filePath) } data, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read file for backup: %w", err) } timestamp := time.Now().Format("2006-01-02_150405.000000000") pattern := filepath.Base(filePath) + inputConfBackupPrefix + "." + timestamp + ".*" backupPath, err := fileops.WriteUnique(filepath.Dir(filePath), pattern, data, info.Mode().Perm()) if err != nil { return fmt.Errorf("failed to create backup file: %w", err) } if err := fileops.PruneBackups(filepath.Dir(filePath), filepath.Base(filePath)+inputConfBackupPrefix+".*", maxInputConfBackups); err != nil { return fmt.Errorf("failed to prune old input.conf backups: %w", err) } log.Info(fmt.Sprintf("Created input.conf backup: %s", backupPath)) return nil }