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:
commit
1febf47dcb
17 changed files with 1446 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/omagrab
|
||||||
|
/dist/
|
||||||
|
*.bak
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
152
README.md
Normal 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
88
config.go
Normal 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
|
||||||
|
}
|
||||||
9
desktop/omagrab-url.desktop
Normal file
9
desktop/omagrab-url.desktop
Normal 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
10
desktop/omagrab.desktop
Normal 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
180
download.go
Normal 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
36
extension/background.js
Normal 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
8
extension/manifest.json
Normal 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
33
go.mod
Normal 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
52
go.sum
Normal 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
50
install-integration.sh
Executable 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
165
install.sh
Executable 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
92
main.go
Normal 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
28
main_test.go
Normal 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
300
model.go
Normal 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
219
view.go
Normal 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"
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue