From 95bedb8ee7b70b16a3a33372c14904929ce31457 Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Fri, 27 Dec 2024 00:07:05 +0200 Subject: [PATCH] feat: allow selection for files to be encoded in a directory --- TODO.md | 2 +- app.go | 153 +++++++++++++++++++++++++++++++++++++------------ cmds.go | 12 ++-- config.go | 35 ++--------- config_test.go | 4 +- main.go | 14 ++--- styles.go | 48 ++++++++++++++++ util.go | 43 ++++++++++++++ 8 files changed, 230 insertions(+), 81 deletions(-) create mode 100644 util.go diff --git a/TODO.md b/TODO.md index f70181a..04bc73b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ - [x] query ffmpeg binary for available encoders and list the ones we know -- [ ] when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded +- [x] when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded - This will of course have a select all checkbox for quickly encoding all the files in a dir. - [ ] calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage. might not do this. diff --git a/app.go b/app.go index 1e78704..d80a5dc 100644 --- a/app.go +++ b/app.go @@ -21,14 +21,14 @@ var X = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2233")).Render("✖️ const ( None Screen = iota - Files Cfg + Files Main ) type File struct { - Name string - Encode bool + Path string + Selected bool } type Model struct { @@ -36,7 +36,7 @@ type Model struct { Path string CurrentFileName string FileCount int - Files []string + Files []File Spinner spinner.Model SingleFileProgressBar progress.Model SingleFileProgress float64 @@ -91,7 +91,8 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *Model { CurrentFileName: fileInfo.Name(), Path: absolutePath, FileCount: 0, - Files: make([]string, 0), + Files: make([]File, 0), + Screen: Cfg, Spinner: s, SingleFileProgressBar: progress.New(progress.WithGradient("#1010ff", "#00ff00")), SingleFileProgress: 0.0, @@ -131,32 +132,61 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.FileCount = msg.fileCount m.Files = msg.files - if len(m.Files) == 1 { - m.Screen = Cfg - } else if len(m.Files) > 1 { - m.Screen = Files - } - return m, nil } switch m.Screen { case Files: - // TODO: navigate files and select them and stuff switch msg := msg.(type) { case tea.KeyMsg: key := msg.String() switch key { case "ctrl+c", "esc": return m, tea.Quit - case "enter", "space": - // TODO: change the file state to Encode = true - // Also change the files to be of File type - // t := m.Files[m.FocusIndex] + case "enter", " ": + if m.FocusIndex == 0 && key == "enter" { + selectAll := !every(m.Files, func(e File) bool { return e.Selected }) + + if m.ChoiceIndex == 0 { + for i := range m.Files { + m.Files[i].Selected = selectAll + } + } else { + m.Files = filter(m.Files, func(f File) bool { + return f.Selected + }) + + m.Screen = Main + m.FileCount = len(m.Files) + + return m, tea.Batch(m.Spinner.Tick, encodeVideo) + } + } else if m.FocusIndex != 0 { + m.Files[m.FocusIndex-1].Selected = !m.Files[m.FocusIndex-1].Selected + + // Reset the choice to "Select All" button when no files are selected + if !anyOf(m.Files, func(f File) bool { return f.Selected }) { + m.ChoiceIndex = 0 + } + } case "g": m.FocusIndex = 0 case "G": - m.FocusIndex = len(m.Files) - 1 + m.FocusIndex = len(m.Files) + case "left", "right", "h", "l": + if m.FocusIndex == 0 && anyOf(m.Files, func(f File) bool { return f.Selected }) { + if key == "right" || key == "l" { + m.ChoiceIndex++ + } else { + m.ChoiceIndex-- + } + + if m.ChoiceIndex > 1 { + m.ChoiceIndex = 0 + } else if m.ChoiceIndex < 0 { + m.ChoiceIndex = 1 + } + } case "tab", "shift+tab", "up", "down", "j", "k": if key == "up" || key == "shift+tab" || key == "k" { m.FocusIndex-- @@ -164,10 +194,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.FocusIndex++ } - if m.FocusIndex >= len(m.Files) { + if m.FocusIndex >= len(m.Files)+1 { m.FocusIndex = 0 } else if m.FocusIndex < 0 { - m.FocusIndex = len(m.Files) - 1 + m.FocusIndex = len(m.Files) } } } @@ -239,13 +269,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Quitting = true return m, tea.Quit case parsedCfgMsg: - m.Screen = Main m.ParsedConfig = msg.parsedConfig + if m.DryRun { return m, tea.Batch(tea.ExitAltScreen, tea.Quit) } - return m, tea.Batch(m.Spinner.Tick, encodeVideo) + if m.IsDirectory { + m.Screen = Files + m.FocusIndex = 0 + m.ChoiceIndex = 0 + } else { + m.Screen = Main + + return m, tea.Batch(m.Spinner.Tick, encodeVideo) + } } case Main: switch msg := msg.(type) { @@ -262,11 +300,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Sequence(tea.ExitAltScreen, tea.Quit) } case encodeVideoMsg: - m.CurrentFileName = filepath.Base(m.Files[len(m.Files)-1]) + m.CurrentFileName = filepath.Base(m.Files[len(m.Files)-1].Path) go func() { - fullFilePath := m.Files[len(m.Files)-1] - encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig) + file := m.Files[len(m.Files)-1] + encode(file, filepath.Base(file.Path), m.Program, m.ParsedConfig) }() return m, nil @@ -276,8 +314,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case finishedEncodingVideo: if m.ParsedConfig.DeleteOldVideo { - m.CurrentFileName = fmt.Sprintf("Deleting: %s", filepath.Base(m.Files[len(m.Files)-1])) - os.Remove(m.Files[len(m.Files)-1]) + m.CurrentFileName = fmt.Sprintf("Deleting: %s", filepath.Base(m.Files[len(m.Files)-1].Path)) + os.Remove(m.Files[len(m.Files)-1].Path) } m.Files = m.Files[:len(m.Files)-1] @@ -333,19 +371,52 @@ func parseConfig(cfg []Config) ParsedConfig { } func FilesScreenView(m Model) string { - view := "" + view := lipgloss.NewStyle().Margin(1, 0).Render("Select the files you wish to encode.") + var files string + var buttons string + var selectAllBtnText string - // TODO: Create a nice view where we can select different videos and then select the encoding options and start - // encoding. - for i, file := range m.Files { - if m.FocusIndex == i { - view += fmt.Sprintf(FocusedConfig.Render("[%s] %s"), " ", filepath.Base(file)) + allSelected := every(m.Files, func(e File) bool { return e.Selected }) + noneSelected := !anyOf(m.Files, func(e File) bool { return e.Selected }) + + if allSelected { + selectAllBtnText = "Deselect All" + } else { + selectAllBtnText = "Select All" + } + + blurredStartButton := BlurredStartButton + focusedStartButton := FocusedStartButton + + if noneSelected { + blurredStartButton = DisabledStartButton + focusedStartButton = DisabledStartButton + } + + if m.FocusIndex == 0 { + if m.ChoiceIndex == 0 { + buttons = lipgloss.JoinHorizontal(0, FocusedSelectAllButton.Render(selectAllBtnText), blurredStartButton) } else { - view += fmt.Sprintf(BlurredConfig.Render("[%s] %s"), " ", filepath.Base(file)) + buttons = lipgloss.JoinHorizontal(0, BlurredSelectAllButton.Render(selectAllBtnText), focusedStartButton) + } + } else { + buttons = lipgloss.JoinHorizontal(0, BlurredSelectAllButton.Render(selectAllBtnText), blurredStartButton) + } + + // TODO: files should be a scrollable grid + for i, file := range m.Files { + selection := " " + if file.Selected { + selection = "x" + } + if m.FocusIndex == i+1 { + files += fmt.Sprintf(FocusedConfig.Render("[%s] %s"), selection, filepath.Base(file.Path)) + } else { + files += fmt.Sprintf(BlurredConfig.Render("[%s] %s"), selection, filepath.Base(file.Path)) } } - view += "\n" + view += lipgloss.JoinVertical(0, buttons, files) return view } @@ -379,9 +450,17 @@ func CfgScreenView(m Model) string { var dryRunButton string if m.FocusIndex == len(m.VisibleConfig) && m.ChoiceIndex == 0 { - startButton = FocusedStartButton + if m.IsDirectory { + startButton = FocusedNextButton + } else { + startButton = FocusedStartButton + } } else { - startButton = BlurredStartButton + if m.IsDirectory { + startButton = BlurredNextButton + } else { + startButton = BlurredStartButton + } } if m.FocusIndex == len(m.VisibleConfig) && m.ChoiceIndex == 1 { @@ -399,7 +478,7 @@ func CfgScreenView(m Model) string { func MainScreenView(m Model) string { progress := "" - if m.IsDirectory { + if m.IsDirectory && m.FileCount > 1 { progress = fmt.Sprintf("%s %d/%d files encoded\nFile Progress: %s\n\nTotal Progress: %s", m.Spinner.View(), m.FileCount-len(m.Files), diff --git a/cmds.go b/cmds.go index 1523a65..5ad7efc 100644 --- a/cmds.go +++ b/cmds.go @@ -27,7 +27,7 @@ type ffmpegProcessStart struct { type filesStatMsg struct { fileCount int - files []string + files []File } func (m *Model) statFiles() tea.Msg { @@ -53,7 +53,7 @@ func (m *Model) statFiles() tea.Msg { } m.FileCount++ - m.Files = append(m.Files, fullFilePath) + m.Files = append(m.Files, File{Path: fullFilePath}) } if len(m.Files) == 0 { @@ -61,7 +61,7 @@ func (m *Model) statFiles() tea.Msg { } } else { m.FileCount = 1 - m.Files = append(m.Files, m.Path) + m.Files = append(m.Files, File{Path: m.Path, Selected: true}) } return filesStatMsg{ @@ -119,10 +119,10 @@ func (m *Model) cleanUp() tea.Msg { return tea.Quit() } - fullFilePath := m.Files[len(m.Files)-1] - fileName := filepath.Base(fullFilePath) + file := m.Files[len(m.Files)-1] + fileName := filepath.Base(file.Path) - parentDir := filepath.Dir(fullFilePath) + parentDir := filepath.Dir(file.Path) extensionIndex := strings.LastIndex(fileName, ".") newFileName := fileName[:extensionIndex] extension := fileName[extensionIndex:] diff --git a/config.go b/config.go index 2b5a0e1..ad57d6c 100644 --- a/config.go +++ b/config.go @@ -51,41 +51,18 @@ func find(cfgs []Config, name string) Config { panic(err) } -func filter(cfgs []Config, names ...string) []Config { - filtered := make([]Config, 0) - -cfgloop: - for _, cfg := range cfgs { - for _, name := range names { - if cfg.Name == name { - continue cfgloop - } - } - - filtered = append(filtered, cfg) - } - - return filtered -} - -func contains[T comparable](slice []T, elem T) bool { - for _, e := range slice { - if e == elem { - return true - } - } - - return false -} - func getVisibleConfigs(cfgs []Config) []Config { parsed := parseConfig(cfgs) switch parsed.VideoEncoder { case "copy", "librav1e": - return filter(cfgs, "Preset", "Constant Rate Factor (CRF)") + return filter(cfgs, func(c Config) bool { + return c.Name != "Preset" && c.Name != "Constant Rate Factor (CRF)" + }) case "libvpx-vp9", "libsvtav1": - return filter(cfgs, "Preset") + return filter(cfgs, func(c Config) bool { + return c.Name != "Preset" + }) } return cfgs diff --git a/config_test.go b/config_test.go index e696d42..28134fe 100644 --- a/config_test.go +++ b/config_test.go @@ -3,7 +3,9 @@ package main import "testing" func TestFilterConfigs(t *testing.T) { - cfgs := filter(Configs, "Preset", "Constant Rate Factor (CRF)") + cfgs := filter(Configs, func(c Config) bool { + return c.Name != "Preset" && c.Name != "Constant Rate Factor (CRF)" + }) for _, cfg := range cfgs { if cfg.Name == "Preset" { diff --git a/main.go b/main.go index c6e8659..75f9745 100644 --- a/main.go +++ b/main.go @@ -57,14 +57,14 @@ func main() { if finalModel.DryRun { for _, file := range finalModel.Files { - parentDir := filepath.Dir(file) - fileName := filepath.Base(file) + parentDir := filepath.Dir(file.Path) + fileName := filepath.Base(file.Path) extensionIndex := strings.LastIndex(fileName, ".") newFileName := fileName[:extensionIndex] extension := fileName[extensionIndex:] outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", finalModel.ParsedConfig.VideoEncoder, finalModel.ParsedConfig.AudioEncoder)+extension) - cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(file, outFileFullPath, finalModel.ParsedConfig)...) + cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(file.Path, outFileFullPath, finalModel.ParsedConfig)...) fmt.Println(fmt.Sprintf("%s %s", Checkmark, cmd.String())) } @@ -118,8 +118,8 @@ func buildFFmpegCmdArgs(fullFilePath string, outFullFilePath string, cfg ParsedC return args } -func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedConfig) { - mType, err := mimetype.DetectFile(fullFilePath) +func encode(file File, fileName string, teaP *tea.Program, cfg ParsedConfig) { + mType, err := mimetype.DetectFile(file.Path) if err != nil { log.Fatal(err) } @@ -128,7 +128,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC log.Fatalf("%s is not a valid video file\n", fileName) } - parentDir := filepath.Dir(fullFilePath) + parentDir := filepath.Dir(file.Path) extensionIndex := strings.LastIndex(fileName, ".") newFileName := fileName[:extensionIndex] extension := fileName[extensionIndex:] @@ -144,7 +144,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC } } - cmdArgs := buildFFmpegCmdArgs(fullFilePath, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(fullFilePath, teaP)) + cmdArgs := buildFFmpegCmdArgs(file.Path, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(file.Path, teaP)) cmd := exec.Command("ffmpeg", cmdArgs...) teaP.Send(ffmpegProcessStart{cmd}) err = cmd.Run() diff --git a/styles.go b/styles.go index bdf18ec..108f6ff 100644 --- a/styles.go +++ b/styles.go @@ -18,6 +18,11 @@ var ( Light: "#1C6DD0", } + DisabledColor = lipgloss.AdaptiveColor{ + Dark: "#444", + Light: "#AAA", + } + BlurredConfig = lipgloss.NewStyle(). Foreground(PrimaryColor). MarginTop(1) @@ -49,6 +54,32 @@ var ( Padding(0, 2). Align(lipgloss.Center). Render("Start Encoding!") + DisabledStartButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(DisabledColor). + Foreground(DisabledColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Render("Start Encoding!") + + FocusedNextButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(AccentColor). + Foreground(AccentColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Bold(true). + Render("Next") + BlurredNextButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(PrimaryColor). + Foreground(PrimaryColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Render("Next") FocusedDryRunButton = lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). @@ -67,4 +98,21 @@ var ( Padding(0, 2). Align(lipgloss.Center). Render("Print FFmpeg command and exit") + + FocusedSelectAllButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(AccentColor). + Foreground(AccentColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Bold(true) + BlurredSelectAllButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(PrimaryColor). + Foreground(PrimaryColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Bold(true) ) diff --git a/util.go b/util.go new file mode 100644 index 0000000..c247b9c --- /dev/null +++ b/util.go @@ -0,0 +1,43 @@ +package main + +func contains[T comparable](slice []T, elem T) bool { + for _, e := range slice { + if e == elem { + return true + } + } + + return false +} + +func every[T any](slice []T, pred func(elem T) bool) bool { + for _, e := range slice { + if !pred(e) { + return false + } + } + + return true +} + +func anyOf[T any](slice []T, pred func(elem T) bool) bool { + for _, e := range slice { + if pred(e) { + return true + } + } + + return false +} + +func filter[T any](slice []T, pred func(elem T) bool) []T { + filtered := make([]T, 0) + + for _, e := range slice { + if pred(e) { + filtered = append(filtered, e) + } + } + + return filtered +}