Files
ffui/app.go
T

337 lines
8.1 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
ffmpeg "github.com/u2takey/ffmpeg-go"
)
type Screen int
var checkmark = lipgloss.NewStyle().Foreground(lipgloss.Color("#22FF33")).Render("✔")
var x = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2233")).Render("✖️")
const (
Cfg Screen = iota
Main
)
type model struct {
IsDirectory bool
Path string
CurrentFileName string
FileCount int
Files []string
Spinner spinner.Model
SingleFileProgressBar progress.Model
SingleFileProgress float64
TotalProgressBar progress.Model
TotalProgress float64
Estimate int
Program *tea.Program
Quitting bool
Screen Screen
Config map[int]Config
FocusIndex int
ParsedConfig ParsedConfig
DryRun bool
ErrQuit bool
ErrQuitMessage string
}
func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
cfg := make(map[int]Config)
for i := 0; i < len(Configs); i++ {
cfg[i] = Configs[i]
}
return &model{
IsDirectory: fileInfo.IsDir(),
CurrentFileName: fileInfo.Name(),
Path: absolutePath,
FileCount: 0,
Files: make([]string, 0),
Spinner: s,
SingleFileProgressBar: progress.New(progress.WithGradient("#1010ff", "#00ff00")),
SingleFileProgress: 0.0,
TotalProgressBar: progress.New(progress.WithDefaultGradient()),
TotalProgress: 0.0,
Quitting: false,
Config: cfg,
ErrQuit: false,
ErrQuitMessage: "",
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(m.Spinner.Tick, initCfg)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
}
case errQuitMsg:
m.ErrQuitMessage = msg.msg
m.ErrQuit = true
return m, m.cleanUp
}
switch m.Screen {
case Cfg:
switch msg := msg.(type) {
case tea.KeyMsg:
key := msg.String()
switch key {
case "enter", " ":
// parse config and switch to main screen if we're focused on the start button
if m.FocusIndex == len(Configs) {
return m, m.parseConfig(false)
}
// parse config, print it and exit
if m.FocusIndex == len(Configs)+1 {
return m, m.parseConfig(true)
}
case "tab", "shift+tab", "up", "down":
if key == "up" || key == "shift+tab" {
m.FocusIndex--
} else {
m.FocusIndex++
}
if m.FocusIndex >= len(Configs)+2 {
m.FocusIndex = 0
} else if m.FocusIndex < 0 {
m.FocusIndex = len(Configs) + 1
}
case "left", "right":
if cfg, ok := m.Config[m.FocusIndex]; ok {
if key == "right" {
cfg.FocusedOption++
} else {
cfg.FocusedOption--
}
if cfg.FocusedOption >= len(cfg.Opts) {
cfg.FocusedOption = 0
} else if cfg.FocusedOption < 0 {
cfg.FocusedOption = len(cfg.Opts) - 1
}
m.Config[m.FocusIndex] = cfg
}
}
case quitMsg:
m.Quitting = true
return m, tea.Quit
case parsedCfgMsg:
m.Screen = Main
m.ParsedConfig = msg.parsedConfig
return m, tea.Batch(m.Spinner.Tick, m.statFile)
}
case Main:
switch msg := msg.(type) {
case initUi:
m.FileCount = msg.fileCount
m.Files = msg.files
if m.DryRun {
return m, gracefullyQuit
}
return m, encodeVideo
case encodeVideoMsg:
m.CurrentFileName = filepath.Base(m.Files[len(m.Files)-1])
go func() {
fullFilePath := m.Files[len(m.Files)-1]
encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig)
}()
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.Files = m.Files[:len(m.Files)-1]
if len(m.Files) == 0 {
return m, gracefullyQuit
}
return m, encodeVideo
case updateProgress:
m.SingleFileProgress = msg.progress
m.TotalProgress = (1.0/float64(m.FileCount))*msg.progress + (1.0 / float64(m.FileCount) * float64(m.FileCount-len(m.Files)))
singleProgressCmd := m.SingleFileProgressBar.SetPercent(msg.progress)
totalProgressCmd := m.TotalProgressBar.SetPercent(m.TotalProgress)
return m, tea.Batch(singleProgressCmd, totalProgressCmd)
case updateEstimate:
m.Estimate = msg.estimate
return m, tea.Batch()
case quitMsg:
m.Quitting = true
return m, tea.Quit
case progress.FrameMsg:
singleProgressModel, singleProgressCmd := m.SingleFileProgressBar.Update(msg)
m.SingleFileProgressBar = singleProgressModel.(progress.Model)
totalProgressModel, totalProgressCmd := m.TotalProgressBar.Update(msg)
m.TotalProgressBar = totalProgressModel.(progress.Model)
return m, tea.Batch(singleProgressCmd, totalProgressCmd)
}
m.Spinner, cmd = m.Spinner.Update(msg)
}
return m, cmd
}
func CfgScreenView(m model) string {
view := ""
for i, cfg := range Configs {
opts := ""
for j, opt := range cfg.Opts {
if m.Config[i].FocusedOption == j {
opts += FocusedOption.Render(opt)
} else {
opts += BlurredOption.Render(opt)
}
if j != len(cfg.Opts)-1 {
opts += ", "
}
}
if i == m.FocusIndex {
view += fmt.Sprintf("%s { %s }\n", FocusedConfig.Render(cfg.Name), opts)
} else {
view += fmt.Sprintf("%s { %s }\n", BlurredConfig.Render(cfg.Name), opts)
}
}
if m.FocusIndex == len(Configs) {
view += FocusedStartButton
} else {
view += BlurredStartButton
}
if m.FocusIndex == len(Configs)+1 {
view += FocusedDryRunButton
} else {
view += BlurredDryRunButton
}
view += "\n"
return view
}
func MainScreenView(m model) string {
progress := ""
if m.IsDirectory {
progress = fmt.Sprintf("%s %d/%d files encoded\nFile Progress: %s\n\nTotal Progress: %s",
m.Spinner.View(),
m.FileCount-len(m.Files),
m.FileCount,
m.SingleFileProgressBar.View(),
m.TotalProgressBar.View())
} else {
progress = fmt.Sprintf("%s %d/%d files encoded\n%s",
m.Spinner.View(),
m.FileCount-len(m.Files),
m.FileCount,
m.SingleFileProgressBar.View())
}
eta := formatEstimate(m.Estimate)
view := fmt.Sprintf("\nEncoding \"%s\"... ETA: %s\n%s", m.CurrentFileName, eta, progress)
return view
}
func formatEstimate(estimate int) string {
if estimate >= 86400 {
day := estimate / 86400
hour := (estimate % 86400) / 3600
min := (estimate % 3600) / 60
sec := estimate % 60
return fmt.Sprintf("%dd%dh%dm%ds", day, hour, min, sec)
} else if estimate >= 3600 {
hour := estimate / 3600
min := (estimate % 3600) / 60
sec := estimate % 60
return fmt.Sprintf("%dh%dm%ds", hour, min, sec)
} else if estimate >= 60 {
min := estimate / 60
sec := estimate % 60
return fmt.Sprintf("%dm%ds", min, sec)
}
return fmt.Sprintf("%ds", estimate)
}
func (m model) View() string {
if m.Quitting {
if m.DryRun {
fullFilePath := m.Files[len(m.Files)-1]
fileName := filepath.Base(fullFilePath)
extensionIndex := strings.LastIndex(fileName, ".")
extension := fileName[extensionIndex:]
newFullFilePath := "./test-out" + extension
cli := ffmpeg.Input(fullFilePath).Output(newFullFilePath, ffmpeg.KwArgs{
"c:v": m.ParsedConfig.VideoEncoder,
"crf": m.ParsedConfig.CRF,
"preset": m.ParsedConfig.Preset,
"c:a": m.ParsedConfig.AudioEncoder,
"b:a": "128k"}).
Compile()
return fmt.Sprintf("%s %s\n", checkmark, strings.Join(cli.Args, " "))
} else {
return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount)
}
}
if m.ErrQuit {
return fmt.Sprintf("%s %s\n", x, m.ErrQuitMessage)
}
switch m.Screen {
case Cfg:
return CfgScreenView(m)
case Main:
return MainScreenView(m)
}
return "Error: Invalid Screen"
}