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) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-08 18:53:53 +01:00
commit 1febf47dcb
17 changed files with 1446 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/omagrab
/dist/
*.bak

21
LICENSE Normal file
View file

@ -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.

152
README.md Normal file
View file

@ -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 <URL> # 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

88
config.go Normal file
View file

@ -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
}

View file

@ -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;

10
desktop/omagrab.desktop Normal file
View file

@ -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

180
download.go Normal file
View file

@ -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]
}

36
extension/background.js Normal file
View file

@ -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],
});
});

8
extension/manifest.json Normal file
View file

@ -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" }
}

33
go.mod Normal file
View file

@ -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
)

52
go.sum Normal file
View file

@ -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=

50
install-integration.sh Executable file
View file

@ -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 <<EOF
Done. Native side is ready.
To enable right-click in Chromium:
1. Open chrome://extensions
2. Toggle Developer mode (top-right)
3. Click Load unpacked and choose:
$here/extension
4. Right-click any link / video page -> "⏬ Download with omagrab"
First click shows Chrome's "Open omagrab?" prompt — tick "Always allow".
EOF

165
install.sh Executable file
View file

@ -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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#16161e"/>
<path d="M128 56 L128 150 M92 116 L128 152 L164 116" fill="none" stroke="#bb9af7" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="64" y="190" width="128" height="12" rx="6" fill="#2a2e42"/>
<rect x="64" y="190" width="84" height="12" rx="6" fill="#bb9af7"/>
</svg>
SVG
gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
cat > "$APP_DIR/omagrab.desktop" <<EOF
[Desktop Entry]
Name=omagrab
Comment=yt-dlp TUI — paste a URL, pick Audio or Video, download
Exec=xdg-terminal-exec --app-id=TUI.float -e $BIN
Icon=omagrab
Terminal=false
Type=Application
Categories=AudioVideo;Network;Utility;
Keywords=yt-dlp;download;video;audio;music;
EOF
bold "Omarchy detected — added floating Walker entry (with icon)."
echo " Launch it from Walker by searching 'omagrab'."
cat <<EOF
Optional: bind clipboard-launch to a key. Copy a link, hit the key, and omagrab
opens with the URL pre-filled. Add to ~/.config/hypr/bindings.conf:
bindd = SUPER SHIFT, V, omagrab, exec, xdg-terminal-exec --app-id=omagrab -e omagrab --clip
and a floating rule to ~/.config/hypr/windows.conf (Hyprland >= 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."

92
main.go Normal file
View file

@ -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 ""
}

28
main_test.go Normal file
View file

@ -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)
}
}
}

300
model.go Normal file
View file

@ -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]
}

219
view.go Normal file
View file

@ -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"
}