From 1febf47dcbafb95f05d7e8ba4125148f9b41f632 Mon Sep 17 00:00:00 2001 From: 28allday Date: Mon, 8 Jun 2026 18:53:53 +0100 Subject: [PATCH] Initial commit: omagrab v0.1.0 A yt-dlp TUI for Omarchy. Dual-mode (audio/video) paste-and-queue downloader built with Go + Bubble Tea. Live progress, config view, clipboard launch, and an install.sh that pulls in yt-dlp/ffmpeg/wl-clipboard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + LICENSE | 21 +++ README.md | 152 ++++++++++++++++++ config.go | 88 +++++++++++ desktop/omagrab-url.desktop | 9 ++ desktop/omagrab.desktop | 10 ++ download.go | 180 ++++++++++++++++++++++ extension/background.js | 36 +++++ extension/manifest.json | 8 + go.mod | 33 ++++ go.sum | 52 +++++++ install-integration.sh | 50 ++++++ install.sh | 165 ++++++++++++++++++++ main.go | 92 +++++++++++ main_test.go | 28 ++++ model.go | 300 ++++++++++++++++++++++++++++++++++++ view.go | 219 ++++++++++++++++++++++++++ 17 files changed, 1446 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.go create mode 100644 desktop/omagrab-url.desktop create mode 100644 desktop/omagrab.desktop create mode 100644 download.go create mode 100644 extension/background.js create mode 100644 extension/manifest.json create mode 100644 go.mod create mode 100644 go.sum create mode 100755 install-integration.sh create mode 100755 install.sh create mode 100644 main.go create mode 100644 main_test.go create mode 100644 model.go create mode 100644 view.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba97486 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/omagrab +/dist/ +*.bak diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5acd172 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gavin Nugent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1230a9b --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# omagrab + +A fast, keyboard-driven **yt-dlp TUI** for [Omarchy](https://omarchy.org) — paste a +URL, pick Audio or Video, and watch it download with live progress. Built with Go + +[Bubble Tea](https://github.com/charmbracelet/bubbletea). + +> **Omarchy only.** This is built specifically for Omarchy and isn't intended to be +> distro-portable. It relies on `xdg-terminal-exec`, Hyprland window rules, +> Wayland + `wl-clipboard`, and a Nerd Font terminal. There are no X11 / +> non-Omarchy fallbacks. + +``` + omagrab + + Audio ▐ Video ▌ + + ▸ paste a URL and press enter… + + ✓ Big Buck Bunny (1080p) done + Sintel — Blender Open Movie ████████░░ 62% 4.2MiB/s + ○ another queued link queued + + tab audio/video · enter add · ↑↓ select · esc quit + empty box → c config · d remove · q quit +``` + +## Features + +- **Works with any [yt-dlp-supported site](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)** + — paste a link and go. +- **Dual mode** — `Tab` toggles ♪ Audio ↔ 🎬 Video. +- **Paste-and-queue** — drop in a URL, press `Enter`, keep adding. A worker + downloads them one at a time with live progress, speed and ETA. +- **Audio** — extracts to your chosen format (opus / mp3 / m4a / flac) with + metadata and thumbnail embedded, into your audio directory. +- **Video** — quality selector (best / 2160 / 1440 / 1080 / 720), merged to mp4, + with optional English subtitles, into your video directory. +- **Config view** — change format, quality and subtitles inline; saved to + `~/.config/omagrab/config.json`. +- **Clipboard launch** — `omagrab --clip` pre-fills the URL from your clipboard, + perfect for a Hyprland keybind (see below). +- **Dependency check** — tells you exactly what to `pacman -S` if anything's missing. + +## Requirements + +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) +- [ffmpeg](https://ffmpeg.org/) (for extraction, merging and embedding) +- `wl-clipboard` (only for the `--clip` / clipboard launch flow) + +```bash +sudo pacman -S yt-dlp ffmpeg wl-clipboard +``` + +## Install + +One line — pulls in `yt-dlp`, `ffmpeg` and `wl-clipboard` via pacman if they're +missing, installs the binary to `~/.local/bin`, and (on Omarchy) adds a floating +Walker entry: + +```bash +curl -fsSL https://raw.githubusercontent.com/28allday/omagrab/main/install.sh | bash +``` + +Or from a clone (builds from source if Go is present, otherwise downloads the +latest released binary): + +```bash +git clone https://github.com/28allday/omagrab.git +cd omagrab +./install.sh +``` + +Make sure `~/.local/bin` is on your `PATH`. The installer prints the optional +`SUPER+SHIFT+V` clipboard keybind for you to add. + +## Usage + +```bash +omagrab # open the TUI +omagrab # open with a URL pre-filled +omagrab --clip # open with the URL from your clipboard pre-filled +omagrab --help +``` + +### Keys + +| Key | Action | +| -------------- | ------------------------------------------------- | +| `Tab` | Switch Audio ↔ Video mode | +| `Enter` | Add the URL in the box to the queue | +| `↑` / `↓` | Select a queue item | +| `c` | Open config (when the URL box is empty) | +| `d` / `x` | Remove the selected item (when the box is empty) | +| `q` / `Esc` | Quit | + +In the config view: `↑↓` move, `←→` change a value, `Esc` saves and returns. + +> Single-letter commands (`c`, `d`, `q`) only act when the URL box is empty — so +> they never get swallowed while you're typing or pasting a link. + +## Clipboard keybind (recommended) + +The smoothest flow on Omarchy: copy a link in your browser, then summon omagrab as +a floating window with the URL already filled in. + +Add to `~/.config/hypr/bindings.conf`: + +```ini +bindd = SUPER SHIFT, V, omagrab, exec, xdg-terminal-exec --app-id=omagrab -e omagrab --clip +``` + +And a floating rule in `~/.config/hypr/windows.conf` (Hyprland ≥ 0.55 syntax): + +```ini +windowrule = float on, match:class ^(omagrab)$ +windowrule = center on, match:class ^(omagrab)$ +windowrule = size 520 260, match:class ^(omagrab)$ +``` + +Reload Hyprland (`hyprctl reload`). Now: **copy a link → `SUPER+SHIFT+V` → +omagrab floats with the URL ready → `Tab` to pick mode → `Enter`.** + +## Configuration + +`~/.config/omagrab/config.json`: + +```json +{ + "audio_dir": "~/Music", + "video_dir": "~/Videos", + "audio_format": "opus", + "video_quality": "best", + "subtitles": false +} +``` + +Edit it directly, or change format / quality / subtitles from the in-app config +view (`c`). Directories support a leading `~`. + +## A note on copyright + +Please only download content you have the right to. That means your own uploads, +content released under a permissive licence (e.g. Creative Commons), public-domain +material, or anything you otherwise have permission to download. Downloading other +people's content without permission may be copyright infringement (in the UK this +can be a civil matter) and generally breaches the terms of service of the sites +involved. omagrab is a convenience wrapper around `yt-dlp`; how you use it is your +responsibility. + +## License + +[MIT](LICENSE) © 2026 Gavin Nugent diff --git a/config.go b/config.go new file mode 100644 index 0000000..13614bf --- /dev/null +++ b/config.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// Config holds user preferences, persisted as JSON under ~/.config/omagrab. +type Config struct { + AudioDir string `json:"audio_dir"` + VideoDir string `json:"video_dir"` + AudioFormat string `json:"audio_format"` // mp3 | opus | flac | m4a + VideoQuality string `json:"video_quality"` // best | 2160 | 1440 | 1080 | 720 + Subtitles bool `json:"subtitles"` +} + +// AudioFormats / VideoQualities are the values cycled through in the config view. +var ( + AudioFormats = []string{"opus", "mp3", "m4a", "flac"} + VideoQualities = []string{"best", "2160", "1440", "1080", "720"} +) + +func defaultConfig() Config { + return Config{ + AudioDir: "~/Music", + VideoDir: "~/Videos", + AudioFormat: "opus", + VideoQuality: "best", + Subtitles: false, + } +} + +func configPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "omagrab", "config.json"), nil +} + +// loadConfig reads the config file, writing defaults on first run. +func loadConfig() (Config, error) { + path, err := configPath() + if err != nil { + return Config{}, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + cfg := defaultConfig() + return cfg, saveConfig(cfg) + } + if err != nil { + return Config{}, err + } + cfg := defaultConfig() + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func saveConfig(cfg Config) error { + path, err := configPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// ExpandHome resolves a leading ~ to the user's home directory. +func ExpandHome(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, strings.TrimPrefix(p, "~")) + } + } + return p +} diff --git a/desktop/omagrab-url.desktop b/desktop/omagrab-url.desktop new file mode 100644 index 0000000..2256860 --- /dev/null +++ b/desktop/omagrab-url.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=omagrab (URL handler) +Comment=Download a link with omagrab +Exec=xdg-terminal-exec omagrab %u +Terminal=false +NoDisplay=true +StartupNotify=false +MimeType=x-scheme-handler/omagrab; diff --git a/desktop/omagrab.desktop b/desktop/omagrab.desktop new file mode 100644 index 0000000..38829a5 --- /dev/null +++ b/desktop/omagrab.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=omagrab +Comment=Download audio/video with yt-dlp +Exec=xdg-terminal-exec omagrab +Terminal=false +Icon=folder-download +Categories=AudioVideo;Utility; +Keywords=yt-dlp;download;video;audio; +StartupNotify=false diff --git a/download.go b/download.go new file mode 100644 index 0000000..169cdf2 --- /dev/null +++ b/download.go @@ -0,0 +1,180 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// progress lines are rendered by yt-dlp from this template; we tag them with a +// sentinel so they're trivially distinguished from yt-dlp's other chatter. +const progSentinel = "OMAGRABPROG|" +const progTemplate = "download:" + progSentinel + + "%(progress._percent_str)s|%(progress._speed_str)s|%(progress._eta_str)s" + +// checkDeps returns the list of required external binaries that are missing. +func checkDeps() []string { + var missing []string + for _, bin := range []string{"yt-dlp", "ffmpeg"} { + if _, err := exec.LookPath(bin); err != nil { + missing = append(missing, bin) + } + } + return missing +} + +// depPackages maps the missing binaries to their Arch package names. +func depPackages(missing []string) string { + pkg := map[string]string{"yt-dlp": "yt-dlp", "ffmpeg": "ffmpeg"} + var out []string + for _, m := range missing { + out = append(out, pkg[m]) + } + return strings.Join(out, " ") +} + +// buildArgs returns the yt-dlp arguments for one queued item, snapshotting the +// current config so later config changes don't affect an already-queued job. +func buildArgs(cfg Config, m mode) []string { + args := []string{ + "--no-warnings", "--newline", + "--progress-template", progTemplate, + "-o", "%(title)s.%(ext)s", + } + if m == audioMode { + args = append(args, + "-P", ExpandHome(cfg.AudioDir), + "-x", "--audio-format", cfg.AudioFormat, + "--audio-quality", "0", + "--embed-metadata", "--embed-thumbnail", + ) + } else { + var sel string + if cfg.VideoQuality == "best" { + sel = "bv*+ba/b" + } else { + sel = fmt.Sprintf("bv*[height<=%s]+ba/b[height<=%s]", cfg.VideoQuality, cfg.VideoQuality) + } + args = append(args, + "-P", ExpandHome(cfg.VideoDir), + "-f", sel, + "--merge-output-format", "mp4", + "--embed-metadata", + ) + if cfg.Subtitles { + args = append(args, "--embed-subs", "--sub-langs", "en.*") + } + } + return args +} + +// worker processes the queue sequentially, streaming events back to the UI. +func worker(p *tea.Program, jobs <-chan *item) { + for it := range jobs { + runOne(p, it) + } +} + +func runOne(p *tea.Program, it *item) { + // 1. resolve a human title (best effort; falls back to the URL). + p.Send(statusMsg{url: it.url, status: stFetching}) + if title := fetchTitle(it.url); title != "" { + p.Send(titleMsg{url: it.url, title: title}) + } + + // 2. download, streaming progress. + p.Send(statusMsg{url: it.url, status: stDownloading}) + args := append(append([]string{}, it.args...), it.url) + cmd := exec.Command("yt-dlp", args...) + + pr, pw := io.Pipe() + cmd.Stdout = pw + cmd.Stderr = pw + + if err := cmd.Start(); err != nil { + p.Send(failMsg{url: it.url, err: err.Error()}) + return + } + + tail := newRing(8) + done := make(chan struct{}) + go func() { + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, progSentinel) { + if pct, speed, eta, ok := parseProg(line); ok { + p.Send(progressMsg{url: it.url, percent: pct, speed: speed, eta: eta}) + } + continue + } + tail.push(line) + } + close(done) + }() + + err := cmd.Wait() + pw.Close() + <-done + + if err != nil { + p.Send(failMsg{url: it.url, err: tail.last()}) + return + } + p.Send(doneMsg{url: it.url}) +} + +// fetchTitle does a lightweight metadata-only lookup. +func fetchTitle(url string) string { + out, err := exec.Command("yt-dlp", "--no-warnings", "--skip-download", + "--no-playlist", "--print", "%(title)s", url).Output() + if err != nil { + return "" + } + return strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) +} + +func parseProg(line string) (pct float64, speed, eta string, ok bool) { + parts := strings.SplitN(strings.TrimPrefix(line, progSentinel), "|", 3) + if len(parts) != 3 { + return 0, "", "", false + } + ps := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(parts[0]), "%")) + pct, err := strconv.ParseFloat(ps, 64) + if err != nil { + return 0, "", "", false + } + return pct / 100, strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true +} + +// ring is a tiny fixed-size line buffer for capturing the tail of stderr. +type ring struct { + buf []string + n int +} + +func newRing(n int) *ring { return &ring{buf: make([]string, 0, n), n: n} } + +func (r *ring) push(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + if len(r.buf) == r.n { + r.buf = r.buf[1:] + } + r.buf = append(r.buf, s) +} + +func (r *ring) last() string { + if len(r.buf) == 0 { + return "download failed" + } + return r.buf[len(r.buf)-1] +} diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..739ed0e --- /dev/null +++ b/extension/background.js @@ -0,0 +1,36 @@ +// Adds a right-click entry that hands the chosen URL to the native omagrab app +// via the custom "omagrab:" URL scheme (registered by omagrab-url.desktop). + +function createMenu() { + // removeAll first so reloads / service-worker restarts can't hit a + // "duplicate id" error that would leave the menu uncreated. + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + id: "omagrab", + title: "⏬ Download with omagrab", + contexts: ["link", "page", "video", "audio"], + }); + }); +} + +chrome.runtime.onInstalled.addListener(createMenu); +chrome.runtime.onStartup.addListener(createMenu); +// also run on every service-worker spin-up, in case the events above were missed +createMenu(); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + // Prefer the link the user clicked; otherwise fall back to the page itself + // (e.g. right-clicking anywhere on a video watch page). + const url = info.linkUrl || info.pageUrl || (tab && tab.url); + if (!url || !tab || tab.id == null) return; + + const target = "omagrab:" + encodeURIComponent(url); + + // Setting location.href on the active tab triggers the OS protocol handler + // without spawning a leftover blank tab. The page itself stays put. + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (u) => { window.location.href = u; }, + args: [target], + }); +}); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..e99f953 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "Send to omagrab", + "version": "0.1.0", + "description": "Right-click a link, video, or page to download it with omagrab.", + "permissions": ["contextMenus", "scripting", "activeTab"], + "background": { "service_worker": "background.js" } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0df55ec --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/28allday/omagrab + +go 1.26.1 + +require ( + github.com/charmbracelet/bubbles v1.0.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.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.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.19 // 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.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e92ed90 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +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/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +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.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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.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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.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= diff --git a/install-integration.sh b/install-integration.sh new file mode 100755 index 0000000..ea29725 --- /dev/null +++ b/install-integration.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Installs omagrab plus its desktop integration: +# - binary -> ~/.local/bin/omagrab +# - Walker entry -> ~/.local/share/applications/omagrab.desktop +# - URL handler -> ~/.local/share/applications/omagrab-url.desktop (omagrab: scheme) +# Then prints how to load the Chromium right-click extension. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bindir="$HOME/.local/bin" +appdir="$HOME/.local/share/applications" + +mkdir -p "$bindir" "$appdir" + +# 1. binary (build if needed) +if [[ ! -x "$here/omagrab" ]]; then + echo "==> building omagrab" + (cd "$here" && go build -o omagrab .) +fi +install -m755 "$here/omagrab" "$bindir/omagrab" +echo "==> installed $bindir/omagrab" + +# 2. desktop entries +install -m644 "$here/desktop/omagrab.desktop" "$appdir/omagrab.desktop" +install -m644 "$here/desktop/omagrab-url.desktop" "$appdir/omagrab-url.desktop" +update-desktop-database "$appdir" 2>/dev/null || true +echo "==> installed desktop entries" + +# 3. register the omagrab: URL scheme +xdg-mime default omagrab-url.desktop x-scheme-handler/omagrab +echo "==> registered handler: $(xdg-mime query default x-scheme-handler/omagrab)" + +case ":$PATH:" in + *":$bindir:"*) ;; + *) echo "!! $bindir is not on your PATH — add it so 'omagrab' resolves" ;; +esac + +cat < "⏬ Download with omagrab" + +First click shows Chrome's "Open omagrab?" prompt — tick "Always allow". +EOF diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3870c01 --- /dev/null +++ b/install.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# install.sh — install omagrab, a yt-dlp TUI for Omarchy / Arch. +# +# Quick install (nothing to clone): +# +# curl -fsSL https://raw.githubusercontent.com/28allday/omagrab/main/install.sh | bash +# +# When run from a git clone it builds from source instead (if Go is present), +# otherwise it downloads the latest released binary for your architecture. +# +# ./install.sh +# +# Environment overrides: +# PREFIX=/somewhere install prefix (default ~/.local → ~/.local/bin) +# OMAGRAB_VERSION=v0.1.0 pin a release (default: latest) +# +# omagrab needs yt-dlp + ffmpeg at run time (and wl-clipboard for --clip); the +# installer fetches them via pacman on Arch if they're missing. On an Omarchy +# desktop it also adds a floating Walker entry. +set -euo pipefail + +REPO="28allday/omagrab" +NAME="omagrab" +PREFIX="${PREFIX:-$HOME/.local}" +BIN_DIR="$PREFIX/bin" +BIN="$BIN_DIR/$NAME" +APP_DIR="$HOME/.local/share/applications" +VERSION="${OMAGRAB_VERSION:-latest}" + +bold() { printf '\033[1m%s\033[0m\n' "$1"; } +warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; } +err() { printf '\033[31m%s\033[0m\n' "$1" >&2; } +log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; } + +# ensure_dep makes sure a command exists, installing its package via pacman on +# Arch if missing. The third arg ("optional") only warns instead of aborting. +ensure_dep() { + local cmd="$1" pkg="$2" optional="${3:-}" + command -v "$cmd" >/dev/null 2>&1 && return 0 + if command -v pacman >/dev/null 2>&1; then + bold "Installing missing dependency: ${pkg}" + sudo pacman -S --needed --noconfirm "$pkg" + elif [ "$optional" = "optional" ]; then + warn "Optional dependency '${cmd}' (${pkg}) not found — install it for full functionality." + else + err "Missing dependency '${cmd}'. Install the '${pkg}' package and re-run." + exit 1 + fi +} + +# Run-time dependencies. +ensure_dep yt-dlp yt-dlp # required: the downloader itself +ensure_dep ffmpeg ffmpeg # required: extraction / merge / embed +ensure_dep wl-paste wl-clipboard optional # optional: needed only for --clip + +mkdir -p "$BIN_DIR" + +# If the script lives next to the source tree, we're in a clone. +SCRIPT_DIR="" +if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +# Detect OS/arch for prebuilt asset names. +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) arch="" ;; +esac + +# ---- obtain the binary ---------------------------------------------------- +TMPBIN="" +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then + bold "Building $NAME from source…" + TMPBIN="$(mktemp)" + trap 'rm -f "$TMPBIN"' EXIT + ( cd "$SCRIPT_DIR" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$TMPBIN" . ) +elif [ -n "$SCRIPT_DIR" ] && [ -n "$arch" ] && [ -x "$SCRIPT_DIR/dist/${NAME}-${os}-${arch}" ]; then + log "Using prebuilt binary from dist/" + TMPBIN="$SCRIPT_DIR/dist/${NAME}-${os}-${arch}" +else + # Download the released binary for this OS/arch (curl-style install). + [ "$os" = "linux" ] || { err "omagrab ships Linux binaries only (detected: $os). Clone the repo and build with Go."; exit 1; } + [ -n "$arch" ] || { err "unsupported architecture: $(uname -m)"; exit 1; } + asset="${NAME}-${os}-${arch}" + if [ "$VERSION" = "latest" ]; then + url="https://github.com/$REPO/releases/latest/download/$asset" + else + url="https://github.com/$REPO/releases/download/$VERSION/$asset" + fi + log "Downloading $asset ($VERSION)…" + TMPBIN="$(mktemp)" + trap 'rm -f "$TMPBIN"' EXIT + if command -v curl >/dev/null 2>&1; then + curl -fSL --proto '=https' --tlsv1.2 -o "$TMPBIN" "$url" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$TMPBIN" "$url" + else + err "need curl or wget to download the binary."; exit 1 + fi +fi + +# ---- install -------------------------------------------------------------- +install -Dm755 "$TMPBIN" "$BIN" +bold "Installed: $BIN" + +# ---- Omarchy desktop integration ----------------------------------------- +# Only on Omarchy: add a Walker entry that opens omagrab in a floating window. +# The TUI.float app-id is matched by Omarchy's stock floating-window rule, so +# no Hyprland configuration is required (same approach as tsplay / omarchy-send). +if command -v omarchy-launch-tui >/dev/null 2>&1 || [ -d "$HOME/.local/share/omarchy" ]; then + mkdir -p "$APP_DIR" + + # Install a bundled icon into the user's hicolor theme — a download arrow over + # a progress bar, in the terminal-accent magenta. + icon_dir="$HOME/.local/share/icons/hicolor/scalable/apps" + mkdir -p "$icon_dir" + cat > "$icon_dir/omagrab.svg" <<'SVG' + + + + + + +SVG + gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true + + cat > "$APP_DIR/omagrab.desktop" <= 0.55): + + windowrule = float on, match:class ^(omagrab)\$ + windowrule = center on, match:class ^(omagrab)\$ + windowrule = size 520 260, match:class ^(omagrab)\$ + +then run 'hyprctl reload'. +EOF +fi + +case ":$PATH:" in + *":$BIN_DIR:"*) : ;; + *) warn "Note: $BIN_DIR is not on your PATH. Add it to use 'omagrab' directly." ;; +esac +echo "Run 'omagrab' to start, or 'omagrab --help' for options." diff --git a/main.go b/main.go new file mode 100644 index 0000000..aaf63eb --- /dev/null +++ b/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +const version = "0.1.0" + +func main() { + var initialURL string + var useClip bool + for _, a := range os.Args[1:] { + switch a { + case "-v", "--version": + fmt.Println("omagrab", version) + return + case "-h", "--help": + fmt.Println("omagrab — a yt-dlp TUI for Omarchy\n\n" + + "Usage: omagrab [URL]\n\n" + + " -c, --clip pre-fill the URL from the clipboard (wl-paste)\n\n" + + "Paste URLs into the queue; Tab switches Audio/Video mode.\n" + + "Launch with a URL (or an omagrab: scheme link) to pre-fill it.\n" + + "Config lives in ~/.config/omagrab/config.json") + return + case "-c", "--clip": + useClip = true + default: + if u := normalizeArgURL(a); u != "" { + initialURL = u + } + } + } + if initialURL == "" && useClip { + initialURL = clipboardURL() + } + + if missing := checkDeps(); len(missing) > 0 { + fmt.Fprintf(os.Stderr, "omagrab: missing required tools: %v\n", missing) + fmt.Fprintf(os.Stderr, "install with: sudo pacman -S %s\n", depPackages(missing)) + os.Exit(1) + } + + cfg, err := loadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "omagrab: config error:", err) + os.Exit(1) + } + + jobs := make(chan *item, 256) + p := tea.NewProgram(newModel(cfg, jobs, initialURL), tea.WithAltScreen()) + go worker(p, jobs) + + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// clipboardURL reads the Wayland clipboard and returns it if it's an http(s) URL. +func clipboardURL() string { + out, err := exec.Command("wl-paste", "-n").Output() + if err != nil { + return "" + } + return normalizeArgURL(string(out)) +} + +// normalizeArgURL accepts either a plain http(s) URL or an "omagrab:" / "omagrab://" +// scheme link (as delivered by the browser extension via xdg-open) and returns a +// clean http(s) URL, or "" if the argument isn't a usable URL. +func normalizeArgURL(a string) string { + s := strings.TrimSpace(a) + for _, pfx := range []string{"omagrab://", "omagrab:"} { + if strings.HasPrefix(s, pfx) { + s = strings.TrimPrefix(s, pfx) + if dec, err := url.QueryUnescape(s); err == nil { + s = dec + } + break + } + } + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + return s + } + return "" +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..a776b14 --- /dev/null +++ b/main_test.go @@ -0,0 +1,28 @@ +package main + +import "testing" + +func TestNormalizeArgURL(t *testing.T) { + want := "https://example.com/watch?v=abc123&t=10s" + cases := map[string]string{ + "plain https": want, + "omagrab scheme": "omagrab:https%3A%2F%2Fexample.com%2Fwatch%3Fv%3Dabc123%26t%3D10s", + "omagrab slashes": "omagrab://https%3A%2F%2Fexample.com%2Fwatch%3Fv%3Dabc123%26t%3D10s", + "unescaped scheme": "omagrab:https://example.com/watch?v=abc123&t=10s", + } + for name, in := range cases { + if got := normalizeArgURL(in); got != want { + t.Errorf("%s: normalizeArgURL(%q) = %q, want %q", name, in, got, want) + } + } + for name, in := range map[string]string{ + "junk": "not a url", + "ftp": "ftp://example.com", + "empty": "", + "scheme only": "omagrab:", + } { + if got := normalizeArgURL(in); got != "" { + t.Errorf("%s: normalizeArgURL(%q) = %q, want empty", name, in, got) + } + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..472835b --- /dev/null +++ b/model.go @@ -0,0 +1,300 @@ +package main + +import ( + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type mode int + +const ( + audioMode mode = iota + videoMode +) + +type status int + +const ( + stQueued status = iota + stFetching + stDownloading + stDone + stFailed +) + +// item is one entry in the download queue. +type item struct { + url string + title string + args []string // yt-dlp args snapshotted at enqueue time + status status + percent float64 + speed string + eta string + err string +} + +func (i *item) label() string { + if i.title != "" { + return i.title + } + return i.url +} + +// --- messages from the worker --- + +type titleMsg struct{ url, title string } +type statusMsg struct { + url string + status status +} +type progressMsg struct { + url string + percent float64 + speed string + eta string +} +type doneMsg struct{ url string } +type failMsg struct{ url, err string } + +type view int + +const ( + queueView view = iota + configView +) + +type model struct { + cfg Config + mode mode + view view + input textinput.Model + spinner spinner.Model + items []*item + cursor int + cfgCur int // selected row in the config view + jobs chan<- *item + w, h int + flash string +} + +func newModel(cfg Config, jobs chan<- *item, initialURL string) model { + ti := textinput.New() + ti.Placeholder = "paste a URL and press enter…" + ti.Prompt = "▸ " + ti.Focus() + ti.CharLimit = 2048 + if initialURL != "" { + ti.SetValue(initialURL) + ti.CursorEnd() + } + + sp := spinner.New() + sp.Spinner = spinner.Dot + + return model{ + cfg: cfg, + mode: videoMode, // start in Video mode + input: ti, + spinner: sp, + jobs: jobs, + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(textinput.Blink, m.spinner.Tick) +} + +// find returns the item with the given URL, or nil. +func (m *model) find(url string) *item { + for _, it := range m.items { + if it.url == url { + return it + } + } + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.w, m.h = msg.Width, msg.Height + m.input.Width = m.cardWidth() - 4 + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case titleMsg: + if it := m.find(msg.url); it != nil { + it.title = msg.title + } + return m, nil + + case statusMsg: + if it := m.find(msg.url); it != nil { + it.status = msg.status + } + return m, nil + + case progressMsg: + if it := m.find(msg.url); it != nil { + it.status = stDownloading + it.percent, it.speed, it.eta = msg.percent, msg.speed, msg.eta + } + return m, nil + + case doneMsg: + if it := m.find(msg.url); it != nil { + it.status, it.percent = stDone, 1 + } + return m, nil + + case failMsg: + if it := m.find(msg.url); it != nil { + it.status, it.err = stFailed, msg.err + } + return m, nil + + case tea.KeyMsg: + if m.view == configView { + return m.updateConfig(msg) + } + return m.updateQueue(msg) + } + return m, nil +} + +func (m model) updateQueue(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.flash = "" + key := msg.String() + switch key { + case "ctrl+c", "esc": + return m, tea.Quit + case "tab": + if m.mode == audioMode { + m.mode = videoMode + } else { + m.mode = audioMode + } + return m, nil + case "enter": + return m.enqueue() + case "up", "ctrl+p": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "down", "ctrl+n": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + return m, nil + } + + // Single-letter commands act only when the URL box is empty (its resting + // state), so they don't get swallowed while you're typing/pasting a URL. + if strings.TrimSpace(m.input.Value()) == "" { + switch key { + case "c": + m.view = configView + return m, nil + case "d", "x": + return m.removeSelected() + case "q": + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m model) enqueue() (tea.Model, tea.Cmd) { + url := strings.TrimSpace(m.input.Value()) + if url == "" { + return m, nil + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + m.flash = "that doesn't look like a URL" + return m, nil + } + it := &item{url: url, status: stQueued, args: buildArgs(m.cfg, m.mode)} + m.items = append(m.items, it) + m.input.SetValue("") + m.jobs <- it + return m, nil +} + +func (m model) removeSelected() (tea.Model, tea.Cmd) { + if m.cursor < 0 || m.cursor >= len(m.items) { + return m, nil + } + it := m.items[m.cursor] + if it.status == stDownloading || it.status == stFetching { + m.flash = "can't remove an active download" + return m, nil + } + m.items = append(m.items[:m.cursor], m.items[m.cursor+1:]...) + if m.cursor >= len(m.items) && m.cursor > 0 { + m.cursor-- + } + return m, nil +} + +// config view rows +const ( + cfgAudioFmt = iota + cfgVideoQual + cfgSubs + cfgRowCount +) + +func (m model) updateConfig(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc", "ctrl+e", "q": + _ = saveConfig(m.cfg) + m.view = queueView + m.flash = "config saved" + return m, nil + case "up", "k": + if m.cfgCur > 0 { + m.cfgCur-- + } + case "down", "j": + if m.cfgCur < cfgRowCount-1 { + m.cfgCur++ + } + case "left", "h", "right", "l", "enter", " ": + m.cycleConfig() + } + return m, nil +} + +func (m *model) cycleConfig() { + switch m.cfgCur { + case cfgAudioFmt: + m.cfg.AudioFormat = cycle(AudioFormats, m.cfg.AudioFormat) + case cfgVideoQual: + m.cfg.VideoQuality = cycle(VideoQualities, m.cfg.VideoQuality) + case cfgSubs: + m.cfg.Subtitles = !m.cfg.Subtitles + } +} + +func cycle(opts []string, cur string) string { + for i, o := range opts { + if o == cur { + return opts[(i+1)%len(opts)] + } + } + return opts[0] +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..2f5e799 --- /dev/null +++ b/view.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Nerd Font glyphs (Omarchy terminals ship a Nerd Font). +const ( + iconDownload = "" // nf-fa-download + iconGear = "" // nf-fa-cog +) + +var ( + accent = lipgloss.Color("13") // magenta + dim = lipgloss.Color("240") + good = lipgloss.Color("10") + bad = lipgloss.Color("9") + fgFaint = lipgloss.Color("245") + + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent) + tabOn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(accent).Padding(0, 1) + tabOff = lipgloss.NewStyle().Foreground(dim).Padding(0, 1) + dimStyle = lipgloss.NewStyle().Foreground(dim) + faintStyle = lipgloss.NewStyle().Foreground(fgFaint) + goodStyle = lipgloss.NewStyle().Foreground(good) + badStyle = lipgloss.NewStyle().Foreground(bad) + cursorStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + helpStyle = lipgloss.NewStyle().Foreground(dim) + flashStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) +) + +func (m model) View() string { + var body string + if m.view == configView { + body = m.configCard() + } else { + body = m.queueCard() + } + // Center the whole card horizontally and vertically in the window. + if m.w > 0 && m.h > 0 { + return lipgloss.Place(m.w, m.h, lipgloss.Center, lipgloss.Center, body) + } + return body +} + +// width returns the usable terminal width, with a sane fallback before the +// first WindowSizeMsg arrives. +func (m model) width() int { + if m.w > 0 { + return m.w + } + return 72 +} + +// cardWidth is the width of the centered content column. +func (m model) cardWidth() int { + w := m.width() - 8 + if w < 44 { + w = 44 + } + if w > 60 { + w = 60 + } + return w +} + +func (m model) tabs() string { + if m.mode == audioMode { + return tabOn.Render("Audio") + " " + tabOff.Render("Video") + } + return tabOff.Render("Audio") + " " + tabOn.Render("Video") +} + +func (m model) queueCard() string { + cw := m.cardWidth() + center := lipgloss.NewStyle().Width(cw).Align(lipgloss.Center) + leftCol := lipgloss.NewStyle().Width(cw).Align(lipgloss.Left) + + title := center.Render(titleStyle.Render(iconDownload + " omagrab")) + tabs := center.Render(m.tabs()) + input := center.Render(m.input.View()) + + var queue string + if len(m.items) == 0 { + queue = center.Render(dimStyle.Render("queue empty — paste a URL above")) + } else { + rows := make([]string, len(m.items)) + for i, it := range m.items { + rows[i] = m.renderItem(i, it, cw) + } + queue = leftCol.Render(strings.Join(rows, "\n")) + } + + help := center.Render( + helpStyle.Render("tab audio/video · enter add · ↑↓ select · esc quit") + "\n" + + helpStyle.Render("empty box → c config · d remove · q quit")) + + parts := []string{title, tabs, "", input, "", queue, "", help} + if m.flash != "" { + parts = append(parts, "", center.Render(flashStyle.Render(m.flash))) + } + return lipgloss.JoinVertical(lipgloss.Center, parts...) +} + +func (m model) renderItem(i int, it *item, w int) string { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("▌ ") + } + + barW := 14 + if w < 72 { + barW = 10 + } + tailBudget := barW + 16 // bar + percent + speed, kept stable so names align + + var icon, tail string + switch it.status { + case stQueued: + icon = dimStyle.Render("○") + tail = dimStyle.Render("queued") + case stFetching: + icon = m.spinner.View() + tail = dimStyle.Render("fetching…") + case stDownloading: + icon = m.spinner.View() + tail = bar(it.percent, barW) + " " + faintStyle.Render(it.speed) + case stDone: + icon = goodStyle.Render("✓") + tail = goodStyle.Render("done") + case stFailed: + icon = badStyle.Render("✗") + tail = badStyle.Render(truncate(it.err, tailBudget)) + } + + nameW := w - 6 - tailBudget + if nameW < 16 { + nameW = 16 + } + if nameW > 50 { + nameW = 50 + } + name := truncate(it.label(), nameW) + return fmt.Sprintf("%s%s %-*s %s", cur, icon, nameW, name, tail) +} + +func (m model) configCard() string { + cw := m.cardWidth() + center := lipgloss.NewStyle().Width(cw).Align(lipgloss.Center) + leftCol := lipgloss.NewStyle().Width(cw).Align(lipgloss.Left) + + title := center.Render(titleStyle.Render(iconGear + " omagrab · config")) + + fields := []struct{ label, val string }{ + {"Audio format", m.cfg.AudioFormat}, + {"Video quality", m.cfg.VideoQuality}, + {"Subtitles", onoff(m.cfg.Subtitles)}, + } + rows := make([]string, len(fields)) + for i, f := range fields { + marker := " " + val := f.val + if i == m.cfgCur { + marker = cursorStyle.Render("▌ ") + val = cursorStyle.Render("‹ " + f.val + " ›") + } + rows[i] = fmt.Sprintf("%s%-16s %s", marker, f.label, val) + } + block := leftCol.Render(strings.Join(rows, "\n")) + + dirs := center.Render( + faintStyle.Render("audio → "+m.cfg.AudioDir) + "\n" + + faintStyle.Render("video → "+m.cfg.VideoDir)) + help := center.Render(helpStyle.Render("↑↓ move · ←→ change · esc save & back")) + + return lipgloss.JoinVertical(lipgloss.Center, title, "", block, "", dirs, "", help) +} + +// bar renders a simple unicode progress bar. +func bar(frac float64, width int) string { + if frac < 0 { + frac = 0 + } + if frac > 1 { + frac = 1 + } + filled := int(frac * float64(width)) + full := lipgloss.NewStyle().Foreground(accent).Render(strings.Repeat("█", filled)) + empty := dimStyle.Render(strings.Repeat("░", width-filled)) + return full + empty + fmt.Sprintf(" %3.0f%%", frac*100) +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n <= 1 { + return string(r[:n]) + } + return string(r[:n-1]) + "…" +} + +func pad(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} + +func onoff(b bool) string { + if b { + return "on" + } + return "off" +}