diff options
| -rw-r--r-- | go.mod | 30 | ||||
| -rw-r--r-- | go.sum | 51 | ||||
| -rw-r--r-- | main.go | 1061 |
3 files changed, 1142 insertions, 0 deletions
@@ -0,0 +1,30 @@ +module yoga + +go 1.24.7 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) @@ -0,0 +1,51 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= @@ -0,0 +1,1061 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const Version = "v0.0.0" + +var ( + videoExtensions = map[string]struct{}{ + ".mp4": {}, + ".mkv": {}, + ".mov": {}, + ".avi": {}, + ".wmv": {}, + ".m4v": {}, + } + tableStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("63")).Padding(0, 1) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true) + filterStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("105")).Padding(1, 2) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + highlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) +) + +type video struct { + Name string + Path string + Duration time.Duration + ModTime time.Time + Size int64 + Err error +} + +type videosLoadedMsg struct { + videos []video + err error + cacheErr error + pending []string + cache *durationCache +} + +type playVideoMsg struct { + path string + err error +} + +type progressUpdateMsg struct { + processed int + total int + done bool +} + +type durationUpdateMsg struct { + path string + duration time.Duration + err error +} + +type loadProgress struct { + mu sync.Mutex + total int + processed int + done bool +} + +func (p *loadProgress) Reset() { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.total = 0 + p.processed = 0 + p.done = false +} + +func (p *loadProgress) SetTotal(total int) { + if p == nil { + return + } + p.mu.Lock() + p.total = total + p.mu.Unlock() +} + +func (p *loadProgress) Increment() { + if p == nil { + return + } + p.mu.Lock() + p.processed++ + p.mu.Unlock() +} + +func (p *loadProgress) MarkDone() { + if p == nil { + return + } + p.mu.Lock() + p.done = true + p.mu.Unlock() +} + +func (p *loadProgress) Snapshot() (processed, total int, done bool) { + if p == nil { + return 0, 0, true + } + p.mu.Lock() + defer p.mu.Unlock() + return p.processed, p.total, p.done +} + +type cacheEntry struct { + DurationSeconds float64 `json:"duration_seconds"` + ModTimeUnix int64 `json:"mod_time_unix"` + Size int64 `json:"size"` +} + +type durationCache struct { + path string + entries map[string]cacheEntry + mu sync.Mutex + dirty bool +} + +func newDurationCache(path string) *durationCache { + return &durationCache{ + path: path, + entries: make(map[string]cacheEntry), + } +} + +func loadDurationCache(path string) (*durationCache, error) { + cache := newDurationCache(path) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return cache, nil + } + return cache, err + } + if len(data) == 0 { + return cache, nil + } + if err := json.Unmarshal(data, &cache.entries); err != nil { + cache.entries = make(map[string]cacheEntry) + return cache, err + } + return cache, nil +} + +func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[path] + if !ok { + return 0, false + } + if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() { + delete(c.entries, path) + c.dirty = true + return 0, false + } + if entry.DurationSeconds <= 0 { + return 0, false + } + return time.Duration(entry.DurationSeconds * float64(time.Second)), true +} + +func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error { + if c == nil || dur <= 0 { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[string]cacheEntry) + } + c.entries[path] = cacheEntry{ + DurationSeconds: dur.Seconds(), + ModTimeUnix: info.ModTime().Unix(), + Size: info.Size(), + } + c.dirty = true + return nil +} + +func (c *durationCache) Flush() error { + if c == nil { + return nil + } + c.mu.Lock() + if !c.dirty { + c.mu.Unlock() + return nil + } + snapshot := make(map[string]cacheEntry, len(c.entries)) + for k, v := range c.entries { + snapshot[k] = v + } + c.dirty = false + c.mu.Unlock() + + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, c.path) +} + +type sortField int + +const ( + sortByName sortField = iota + sortByDuration + sortByAge +) + +type filterState struct { + name string + minEnabled bool + minMinutes int + maxEnabled bool + maxMinutes int +} + +type filterInputs struct { + fields []textinput.Model + focus int +} + +type model struct { + table table.Model + videos []video + filtered []video + filters filterState + inputs filterInputs + showFilters bool + sortField sortField + sortAscending bool + statusMessage string + loading bool + err error + root string + progress *loadProgress + cachePath string + cache *durationCache + pendingDurations []string + durationTotal int + durationDone int + durationInFlight int + cropValue string + cropEnabled bool +} + +func main() { + crop := flag.String("crop", "", "Optional crop aspect for VLC (e.g. 5:4)") + printVersion := flag.Bool("version", false, "Print version and exit") + flag.Parse() + + if *printVersion { + fmt.Println("Yoga version", Version) + os.Exit(0) + } + + root := mustWorkspaceRoot() + m := newModel(root, strings.TrimSpace(*crop)) + if err := tea.NewProgram(m, tea.WithAltScreen()).Start(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func mustWorkspaceRoot() string { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "cannot determine working directory: %v\n", err) + os.Exit(1) + } + return cwd +} + +func newModel(root, vlcCrop string) model { + columns := []table.Column{ + {Title: headerStyle.Render("Name"), Width: 50}, + {Title: headerStyle.Render("Duration"), Width: 12}, + {Title: headerStyle.Render("Age"), Width: 14}, + {Title: headerStyle.Render("Path"), Width: 40}, + } + + tbl := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(15), + ) + tbl.SetStyles(table.DefaultStyles()) + + nameInput := textinput.New() + nameInput.Placeholder = "substring" + nameInput.Prompt = "Name: " + nameInput.CharLimit = 256 + + minInput := textinput.New() + minInput.Placeholder = "min minutes" + minInput.Prompt = "Min minutes: " + minInput.CharLimit = 4 + minInput.SetValue("") + + maxInput := textinput.New() + maxInput.Placeholder = "max minutes" + maxInput.Prompt = "Max minutes: " + maxInput.CharLimit = 4 + maxInput.SetValue("") + + inputs := filterInputs{ + fields: []textinput.Model{nameInput, minInput, maxInput}, + focus: 0, + } + inputs.fields[0].Focus() + + progress := &loadProgress{} + cachePath := filepath.Join(root, ".video_duration_cache.json") + + return model{ + table: tbl, + inputs: inputs, + sortField: sortByName, + sortAscending: true, + statusMessage: "Scanning for videos...", + loading: true, + root: root, + progress: progress, + cachePath: cachePath, + cropValue: vlcCrop, + cropEnabled: vlcCrop != "", + } +} + +func (m model) Init() tea.Cmd { + if m.progress != nil { + m.progress.Reset() + } + loadCmd := loadVideosCmd(m.root, m.cachePath, m.progress) + if m.progress != nil { + return tea.Batch(loadCmd, progressTickerCmd(m.progress)) + } + return loadCmd +} + +func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd { + return func() tea.Msg { + cache, cacheErr := loadDurationCache(cachePath) + vids, pending, err := loadVideos(root, cache, progress) + if progress != nil { + progress.MarkDone() + } + return videosLoadedMsg{videos: vids, err: err, cacheErr: cacheErr, pending: pending, cache: cache} + } +} + +func progressTickerCmd(progress *loadProgress) tea.Cmd { + if progress == nil { + return nil + } + return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { + processed, total, done := progress.Snapshot() + return progressUpdateMsg{processed: processed, total: total, done: done} + }) +} + +func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) { + var paths []string + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !isVideo(path) { + return nil + } + paths = append(paths, path) + return nil + }) + if err != nil { + return nil, nil, err + } + if progress != nil { + progress.SetTotal(len(paths)) + } + + videos := make([]video, 0, len(paths)) + pending := make([]string, 0) + for _, path := range paths { + info, statErr := os.Stat(path) + if statErr != nil { + videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr}) + if progress != nil { + progress.Increment() + } + continue + } + var dur time.Duration + if cache != nil { + if cached, ok := cache.Lookup(path, info); ok { + dur = cached + } else { + pending = append(pending, path) + } + } else { + pending = append(pending, path) + } + videos = append(videos, video{ + Name: filepath.Base(path), + Path: path, + Duration: dur, + ModTime: info.ModTime(), + Size: info.Size(), + Err: nil, + }) + if progress != nil { + progress.Increment() + } + } + + return videos, pending, nil +} + +func playVideoCmd(path, crop string) tea.Cmd { + return func() tea.Msg { + args := []string{} + if crop != "" { + args = append(args, "--crop", crop) + } + args = append(args, path) + cmd := exec.Command("vlc", args...) + if err := cmd.Start(); err != nil { + return playVideoMsg{path: path, err: err} + } + go func() { + _ = cmd.Wait() + }() + return playVideoMsg{path: path} + } +} + +func probeDurationsCmd(path string, cache *durationCache) tea.Cmd { + return func() tea.Msg { + dur, err := probeDuration(path) + if err == nil && cache != nil { + if info, statErr := os.Stat(path); statErr == nil { + _ = cache.Record(path, info, dur) + } + } + return durationUpdateMsg{path: path, duration: dur, err: err} + } +} + +func probeDuration(path string) (time.Duration, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) + out, err := cmd.Output() + if err != nil { + return 0, err + } + raw := strings.TrimSpace(string(out)) + if raw == "" { + return 0, errors.New("empty duration") + } + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, err + } + return time.Duration(f * float64(time.Second)), nil +} + +func isVideo(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + _, ok := videoExtensions[ext] + return ok +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyMsg(msg) + case progressUpdateMsg: + if m.loading { + if msg.total > 0 && !msg.done { + m.statusMessage = fmt.Sprintf("Loading videos %d/%d...", msg.processed, msg.total) + } else if msg.done { + if msg.total == 0 { + m.statusMessage = "No videos found" + } else { + m.statusMessage = fmt.Sprintf("Loaded %d videos", msg.total) + } + } + } + if msg.done { + return m, nil + } + return m, progressTickerCmd(m.progress) + case durationUpdateMsg: + if msg.path != "" { + m.updateVideoDuration(msg.path, msg.duration, msg.err) + m.durationDone++ + if msg.err != nil { + m.statusMessage = fmt.Sprintf("Duration error for %s: %v", filepath.Base(msg.path), msg.err) + } else if m.durationTotal > 0 { + m.statusMessage = fmt.Sprintf("Probing durations %d/%d...", m.durationDone, m.durationTotal) + } + } + if m.durationInFlight > 0 { + m.durationInFlight-- + } + selectedPath := "" + if idx := m.table.Cursor(); idx >= 0 && idx < len(m.filtered) { + selectedPath = m.filtered[idx].Path + } + m.applyFiltersAndSort() + if selectedPath != "" { + m.restoreSelection(selectedPath) + } + if m.durationDone >= m.durationTotal && m.durationInFlight == 0 { + if m.cache != nil { + if err := m.cache.Flush(); err != nil { + m.statusMessage = fmt.Sprintf("Duration cache flush error: %v", err) + } else { + m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered)) + } + } else { + m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered)) + } + m.pendingDurations = nil + m.durationTotal = 0 + m.durationDone = 0 + m.durationInFlight = 0 + return m, nil + } + if cmd := m.dequeueDurationCmd(); cmd != nil { + return m, cmd + } + return m, nil + case videosLoadedMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + m.statusMessage = fmt.Sprintf("error: %v", msg.err) + } + m.videos = msg.videos + m.cache = msg.cache + m.pendingDurations = msg.pending + m.durationTotal = len(msg.pending) + m.durationDone = 0 + m.applyFiltersAndSort() + if len(m.filtered) == 0 { + m.statusMessage = "No videos found" + } else { + if len(msg.pending) > 0 { + if msg.cacheErr != nil { + m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr) + } else { + m.statusMessage = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered)) + } + } else if msg.cacheErr != nil { + m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr) + } else { + m.statusMessage = fmt.Sprintf("Loaded %d videos", len(m.filtered)) + } + } + m.durationInFlight = 0 + if len(msg.pending) == 0 { + return m, nil + } + cmd := m.startDurationWorkers() + if cmd == nil { + return m, nil + } + return m, cmd + case playVideoMsg: + if msg.err != nil { + m.statusMessage = fmt.Sprintf("Failed to launch VLC: %v", msg.err) + return m, nil + } + m.statusMessage = fmt.Sprintf("Playing via VLC: %s", trimPath(msg.path)) + return m, nil + } + + if m.showFilters { + updated, cmd := m.updateFilterInputs(msg) + m.inputs = updated + return m, cmd + } + + tbl, cmd := m.table.Update(msg) + m.table = tbl + return m, cmd +} + +func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + } + + if m.loading { + return m, nil + } + + if m.showFilters { + switch msg.String() { + case "esc": + m.showFilters = false + m.statusMessage = "Filter closed" + return m, nil + case "enter": + if err := m.applyFilterInputs(); err != nil { + m.statusMessage = err.Error() + } else { + m.showFilters = false + m.applyFiltersAndSort() + m.statusMessage = fmt.Sprintf("Filters applied (%d videos)", len(m.filtered)) + } + return m, nil + case "tab": + m.inputs.focus = (m.inputs.focus + 1) % len(m.inputs.fields) + case "shift+tab": + m.inputs.focus = (m.inputs.focus - 1 + len(m.inputs.fields)) % len(m.inputs.fields) + default: + // no-op; handled below + } + for i := range m.inputs.fields { + if i == m.inputs.focus { + m.inputs.fields[i].Focus() + } else { + m.inputs.fields[i].Blur() + } + } + updated, cmd := m.updateFilterInputs(msg) + m.inputs = updated + return m, cmd + } + + switch msg.String() { + case "f": + m.showFilters = true + m.statusMessage = "Editing filters" + return m, nil + case "enter": + if len(m.filtered) == 0 { + return m, nil + } + idx := m.table.Cursor() + if idx < 0 || idx >= len(m.filtered) { + return m, nil + } + vid := m.filtered[idx] + m.statusMessage = fmt.Sprintf("Launching VLC: %s", vid.Name) + return m, playVideoCmd(vid.Path, m.activeCrop()) + case "n": + m.toggleSort(sortByName) + case "l": + m.toggleSort(sortByDuration) + case "a": + m.toggleSort(sortByAge) + case "c": + if m.cropValue == "" { + m.statusMessage = "No crop value set (start with --crop)" + return m, nil + } + m.cropEnabled = !m.cropEnabled + if m.cropEnabled { + m.statusMessage = fmt.Sprintf("Crop enabled (%s)", m.cropValue) + } else { + m.statusMessage = "Crop disabled" + } + return m, nil + case "r": + m.resetFilters() + m.applyFiltersAndSort() + m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered)) + default: + tbl, cmd := m.table.Update(msg) + m.table = tbl + return m, cmd + } + + m.applyFiltersAndSort() + m.statusMessage = fmt.Sprintf("Sorted %d videos", len(m.filtered)) + return m, nil +} + +func (m model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) { + inputs := m.inputs + var cmds []tea.Cmd + for i := range inputs.fields { + var cmd tea.Cmd + inputs.fields[i], cmd = inputs.fields[i].Update(msg) + cmds = append(cmds, cmd) + } + return inputs, tea.Batch(cmds...) +} + +func (m *model) applyFilterInputs() error { + name := strings.TrimSpace(m.inputs.fields[0].Value()) + minText := strings.TrimSpace(m.inputs.fields[1].Value()) + maxText := strings.TrimSpace(m.inputs.fields[2].Value()) + + filters := filterState{name: name} + + if minText != "" { + minVal, err := strconv.Atoi(minText) + if err != nil { + return fmt.Errorf("invalid min minutes: %q", minText) + } + if minVal < 0 { + return fmt.Errorf("min minutes must be positive") + } + filters.minEnabled = true + filters.minMinutes = minVal + } + + if maxText != "" { + maxVal, err := strconv.Atoi(maxText) + if err != nil { + return fmt.Errorf("invalid max minutes: %q", maxText) + } + if maxVal < 0 { + return fmt.Errorf("max minutes must be positive") + } + filters.maxEnabled = true + filters.maxMinutes = maxVal + } + + if filters.minEnabled && filters.maxEnabled && filters.minMinutes > filters.maxMinutes { + return errors.New("min minutes cannot exceed max minutes") + } + + m.filters = filters + return nil +} + +func (m *model) resetFilters() { + m.filters = filterState{} + for i := range m.inputs.fields { + m.inputs.fields[i].SetValue("") + } +} + +func (m *model) updateVideoDuration(path string, dur time.Duration, err error) { + for i := range m.videos { + if m.videos[i].Path == path { + m.videos[i].Duration = dur + if err != nil { + m.videos[i].Err = err + } else { + m.videos[i].Err = nil + } + break + } + } +} + +func (m *model) restoreSelection(path string) { + for i, v := range m.filtered { + if v.Path == path { + m.table.SetCursor(i) + return + } + } +} + +func (m model) activeCrop() string { + if m.cropEnabled && m.cropValue != "" { + return m.cropValue + } + return "" +} + +func (m *model) dequeueDurationCmd() tea.Cmd { + if len(m.pendingDurations) == 0 { + return nil + } + path := m.pendingDurations[0] + m.pendingDurations = m.pendingDurations[1:] + m.durationInFlight++ + return probeDurationsCmd(path, m.cache) +} + +func (m *model) startDurationWorkers() tea.Cmd { + if len(m.pendingDurations) == 0 { + return nil + } + workers := runtime.NumCPU() + if workers < 1 { + workers = 1 + } + if workers > 6 { + workers = 6 + } + if workers > len(m.pendingDurations) { + workers = len(m.pendingDurations) + } + cmds := make([]tea.Cmd, 0, workers) + for i := 0; i < workers; i++ { + if cmd := m.dequeueDurationCmd(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *model) toggleSort(target sortField) { + if m.sortField == target { + m.sortAscending = !m.sortAscending + } else { + m.sortField = target + m.sortAscending = true + } +} + +func (m *model) applyFiltersAndSort() { + filtered := make([]video, 0, len(m.videos)) + for _, v := range m.videos { + if !m.passesFilters(v) { + continue + } + filtered = append(filtered, v) + } + + sort.Slice(filtered, func(i, j int) bool { + a, b := filtered[i], filtered[j] + less := false + switch m.sortField { + case sortByName: + less = strings.ToLower(a.Name) < strings.ToLower(b.Name) + case sortByDuration: + less = a.Duration < b.Duration + case sortByAge: + less = a.ModTime.Before(b.ModTime) + } + if m.sortAscending { + return less + } + return !less + }) + + m.filtered = filtered + rows := make([]table.Row, 0, len(filtered)) + for _, v := range filtered { + rows = append(rows, videoRow(v)) + } + m.table.SetRows(rows) + if len(rows) > 0 { + m.table.SetCursor(0) + } +} + +func (m model) passesFilters(v video) bool { + f := m.filters + if f.name != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(f.name)) { + return false + } + durMinutes := int(v.Duration.Round(time.Minute) / time.Minute) + if f.minEnabled && (v.Duration == 0 || durMinutes < f.minMinutes) { + return false + } + if f.maxEnabled && (v.Duration == 0 || durMinutes > f.maxMinutes) { + return false + } + return true +} + +func videoRow(v video) table.Row { + duration := "(unknown)" + if v.Duration > 0 { + duration = formatDuration(v.Duration) + } + age := humanizeAge(v.ModTime) + path := trimPath(v.Path) + if v.Err != nil { + duration = "!" + v.Err.Error() + } + return table.Row{v.Name, duration, age, path} +} + +func renderProgressBar(done, total, width int) string { + if width <= 0 || total <= 0 { + return "" + } + if done < 0 { + done = 0 + } + if done > total { + done = total + } + filled := int(float64(done) / float64(total) * float64(width)) + if filled > width { + filled = width + } + bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled) + return fmt.Sprintf("[%s]", bar) +} + +func formatDuration(d time.Duration) string { + if d <= 0 { + return "--" + } + totalSeconds := int(d.Seconds() + 0.5) + hours := totalSeconds / 3600 + minutes := (totalSeconds % 3600) / 60 + seconds := totalSeconds % 60 + if hours > 0 { + return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) + } + return fmt.Sprintf("%02d:%02d", minutes, seconds) +} + +func humanizeAge(t time.Time) string { + if t.IsZero() { + return "--" + } + now := time.Now() + dur := now.Sub(t) + if dur < time.Minute { + return "just now" + } + if dur < time.Hour { + return fmt.Sprintf("%dm ago", int(dur.Minutes())) + } + if dur < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(dur.Hours())) + } + return t.Format("2006-01-02") +} + +func trimPath(path string) string { + home, err := os.UserHomeDir() + if err == nil { + if strings.HasPrefix(path, home) { + return "~" + strings.TrimPrefix(path, home) + } + } + return path +} + +func (m model) View() string { + if m.loading { + return statusStyle.Render("Loading videos, please wait...") + } + + if m.err != nil && len(m.filtered) == 0 { + return statusStyle.Render(fmt.Sprintf("Failed to load videos: %v", m.err)) + } + + cropHelp := "Crop: (no crop configured; start with --crop)" + if m.cropValue != "" { + state := "off" + if m.cropEnabled { + state = "on" + } + cropHelp = fmt.Sprintf("Crop: c=toggle (%s %s)", state, m.cropValue) + } + helpLines := []string{ + "Controls: ↑/↓ move • Enter selects (noop) • q quits", + "Sorting: n=name • l=length • a=age • r=reset filters", + "Filters: f=toggle filter editor (tab to navigate, enter to apply, esc to cancel)", + cropHelp, + } + info := statusStyle.Render(m.statusMessage) + + progressLine := "" + if m.durationTotal > 0 { + bar := renderProgressBar(m.durationDone, m.durationTotal, 24) + progressLine = statusStyle.Render(fmt.Sprintf("Duration scan %s %d/%d", bar, m.durationDone, m.durationTotal)) + } + + content := tableStyle.Render(m.table.View()) + help := strings.Join(helpLines, "\n") + + var parts []string + parts = append(parts, content) + if progressLine != "" { + parts = append(parts, progressLine) + } + parts = append(parts, info, help) + body := strings.Join(parts, "\n") + + if m.showFilters { + return body + "\n\n" + m.renderFilterModal() + } + + return body +} + +func (m model) renderFilterModal() string { + var b strings.Builder + b.WriteString("Filter videos\n") + b.WriteString("(Enter to apply, Esc to cancel)\n\n") + labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"} + for i, field := range m.inputs.fields { + line := fmt.Sprintf("%s %s", labels[i], field.View()) + if i == m.inputs.focus { + line = highlightStyle.Render(line) + } + b.WriteString(line) + b.WriteString("\n") + } + if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" { + b.WriteString("\nCurrent filter: ") + b.WriteString(m.describeFilters()) + b.WriteString("\n") + } + return filterStyle.Render(b.String()) +} + +func (m model) describeFilters() string { + parts := []string{} + if m.filters.name != "" { + parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name)) + } + if m.filters.minEnabled { + parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes)) + } + if m.filters.maxEnabled { + parts = append(parts, fmt.Sprintf("<=%d min", m.filters.maxMinutes)) + } + if len(parts) == 0 { + return "(none)" + } + return strings.Join(parts, ", ") +} |
