diff --git a/.gitignore b/.gitignore
@@ -1,3 +1,4 @@
/target
SPECIFICATION.md
.claude/
+/site/public/
diff --git a/README.md b/README.md
@@ -65,10 +65,16 @@ PulseAudio or ALSA is required for audio output on Linux.
## Install
-Make sure you have [Rust](https://rustup.rs/) installed, then run:
+On Arch Linux:
```bash
-cargo install --git https://github.com/sqzer-x/ommp
+yay -S ommp
+```
+
+Anywhere else, with [Rust](https://rustup.rs/) installed:
+
+```bash
+cargo install ommp
```
That's it. Now you can run `ommp` from anywhere:
@@ -106,7 +112,7 @@ cargo build --release
Place your music files in `~/Music` and run `ommp`. It will automatically scan all files and subdirectories.
-Album art is detected automatically — just place `cover.jpg`, `folder.jpg`, `front.jpg`, or any image file in the same directory as your tracks.
+Album art comes from the audio files themselves — whatever cover is embedded in the tags. Nothing to place alongside them.
### Browsing
@@ -114,7 +120,7 @@ The screen is split into three panels:
- **Left** — Library browser (artists, albums, genres, etc. depending on the active tab)
- **Center** — Play queue
-- **Right** — Album art / Clock (top) + Lyrics (bottom)
+- **Right** — Album art / Clock (top) + Track info (bottom)
Switch browsing modes using the tabs at the top: Queue, Directories, Artists, Albums, Genre, Format, Playlists
diff --git a/assets/screenshot.png b/assets/screenshot.png
Binary files differ.
diff --git a/site/content/_index.md b/site/content/_index.md
@@ -0,0 +1,107 @@
++++
+title = "ommp — Oh My Music Player"
+description = "A terminal music player written in Rust. No MPD, no daemon — point it at ~/Music and press play."
+template = "landing.html"
+
+[extra]
+kicker = "Terminal music player"
+headline = "Point it at ~/Music."
+headline2 = "Press play."
+lede = "No MPD. No daemon. No config file. One binary that reads your tags, draws your album art in the terminal, and picks up where you left off."
+
+install_primary_label = "Arch Linux"
+install_primary = "yay -S ommp"
+install_secondary_label = "Anywhere else"
+install_secondary = "cargo install ommp"
+
+shot_main = "img/01-main.png"
+shot_help = "img/02-help.png"
+shot_search = "img/03-search.png"
+
+# Each feature is keyed to the accent colour its tab actually uses in
+# src/ui/widgets/tab_bar.rs, so the page and the app agree.
+[[extra.features]]
+name = "Queue"
+color = "#64DCFF"
+body = "Everything queues by default. Add an album without losing what is playing, drop a track with <code>d</code>, clear it with <code>c</code>."
+
+[[extra.features]]
+name = "Directories"
+color = "#78FFB4"
+body = "Browse the folders you actually organised, not a database's idea of them."
+
+[[extra.features]]
+name = "Artists"
+color = "#FFB464"
+body = "Grouped by album artist where it is tagged, track artist where it is not. Untagged files land under one heading instead of vanishing."
+
+[[extra.features]]
+name = "Albums"
+color = "#C882FF"
+body = "Paired with their artist, so two records called <em>Greatest Hits</em> stay two records."
+
+[[extra.features]]
+name = "Genre"
+color = "#FF7896"
+body = "Whatever your tags say. ommp reads them, it does not invent them."
+
+[[extra.features]]
+name = "Format"
+color = "#78C8FF"
+body = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC, WMA — with a count beside each."
+
+[[extra.features]]
+name = "Playlists"
+color = "#FFDC64"
+body = "Create, rename, star a track with <code>b</code>. Saved between sessions, and entries survive a drive that was not mounted."
+
+[[extra.numbers]]
+value = "18 ms"
+label = "to open a 5,000-track library"
+note = "Tags are cached and re-read only when a file's timestamp or size changes. The first scan runs across every core."
+
+[[extra.numbers]]
+value = "0.3 ms"
+label = "per frame at 50,000 tracks"
+note = "Flat, not linear — the browse views are built once when the library loads, not on every redraw."
+
+[[extra.numbers]]
+value = "7 MB"
+label = "one binary, three libraries"
+note = "Links against alsa-lib, glibc and gcc-libs. Nothing to configure, nothing running in the background."
+
+[[extra.keys]]
+key = "Space"
+desc = "Play / pause"
+[[extra.keys]]
+key = "n / N"
+desc = "Next / previous"
+[[extra.keys]]
+key = "+ / -"
+desc = "Volume"
+[[extra.keys]]
+key = "s / r"
+desc = "Shuffle / repeat"
+[[extra.keys]]
+key = "1–7"
+desc = "Switch tab"
+[[extra.keys]]
+key = "Tab"
+desc = "Cycle pane focus"
+[[extra.keys]]
+key = "Ctrl+S"
+desc = "Search"
+[[extra.keys]]
+key = "Ctrl+H"
+desc = "Every other key"
+
+[[extra.protocols]]
+name = "Kitty graphics"
+terms = "Ghostty, Kitty, WezTerm, Konsole"
+[[extra.protocols]]
+name = "Sixel"
+terms = "foot, WezTerm, xterm, Contour"
+[[extra.protocols]]
+name = "iTerm2"
+terms = "WezTerm, Konsole"
++++
diff --git a/site/sqzass.toml b/site/sqzass.toml
@@ -0,0 +1,16 @@
+title = "ommp"
+description = "Oh My Music Player — a terminal music player written in Rust. No MPD, no daemon."
+
+# The whole URL, path included: sqzass splits this into an origin and a base
+# path, and prefixes every page and asset URL with the path. Serving from a
+# repository subpath needs nothing else.
+base_url = "https://sqzer-x.github.io/ommp"
+
+[search]
+# One page. A full-text index nobody fetches is just bytes.
+enabled = false
+
+[highlight]
+# The install commands are styled by hand in the template, so the generated
+# syntax stylesheet would go unused.
+enabled = false
diff --git a/site/static/css/main.css b/site/static/css/main.css
@@ -0,0 +1,307 @@
+/* ommp landing page.
+ *
+ * Every colour here is lifted from the application's own source rather than
+ * invented, so the page and the thing it is advertising cannot drift apart.
+ * The comments name where each one lives. */
+
+/* Relative URLs, not root-absolute: this file is served from a repository
+ subpath and the stylesheet's own name is content-hashed, but ../fonts/
+ resolves correctly either way. */
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("../fonts/jetbrains-mono-400.woff2") format("woff2");
+ font-weight: 400; font-style: normal; font-display: swap;
+}
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("../fonts/jetbrains-mono-700.woff2") format("woff2");
+ font-weight: 700; font-style: normal; font-display: swap;
+}
+@font-face {
+ font-family: "Bricolage Grotesque";
+ src: url("../fonts/bricolage-grotesque-var.woff2") format("woff2");
+ font-weight: 400 800; font-style: normal; font-display: swap;
+}
+
+:root {
+ /* about_modal.rs */
+ --bg: #0A051E; /* BG_DARK */
+ --pink: #FF3296; /* NEON_PINK, the logo gradient's start */
+ --cyan: #00FFFF; /* NEON_CYAN, its end */
+ /* theme.rs */
+ --sky: #64DCFF; /* border_focused, and the current track */
+ --dim: #46505F; /* border_unfocused */
+ --violet: #C850FF; /* progress_filled */
+ /* status_bar.rs */
+ --playing: #50FF78;
+
+ --ink: #EDEAF6;
+ --ink-2: #A9A6BC;
+ --panel: #120B2E;
+ --line: #241945;
+
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --disp: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
+
+ --wrap: 68rem;
+ --sb-h: 3.25rem;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
+}
+
+body {
+ margin: 0;
+ padding-bottom: var(--sb-h);
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--disp);
+ font-size: clamp(1rem, .95rem + .2vw, 1.125rem);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* The terminal face is reserved for things that are literally the app:
+ commands you type, keys you press, tab names, the status bar. */
+code, kbd, .cmd, .kicker, .install-label, .feature-name,
+.number-label, .protocols th, .sb-row { font-family: var(--mono); }
+
+a { color: var(--sky); text-underline-offset: .2em; text-decoration-thickness: 1px; }
+a:hover { color: var(--cyan); }
+:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; border-radius: 2px; }
+
+.skip {
+ position: absolute; left: -9999px;
+ background: var(--cyan); color: var(--bg); padding: .6rem 1rem; font-weight: 700;
+}
+.skip:focus { left: 1rem; top: 1rem; z-index: 100; }
+
+.wrap { width: min(100% - 2.5rem, var(--wrap)); margin-inline: auto; }
+
+/* ── Hero ─────────────────────────────────────────────────────────── */
+
+.hero {
+ position: relative;
+ padding-block: clamp(3rem, 8vw, 6rem) 0;
+ /* The splash screen's scattered-note field, reduced to two soft washes. */
+ background:
+ radial-gradient(60rem 32rem at 18% -8%, #3C145055, transparent 70%),
+ radial-gradient(52rem 30rem at 88% 4%, #143C5055, transparent 70%);
+}
+.hero-in { text-align: center; }
+
+.hero-logo {
+ width: min(100%, 26rem); height: auto; display: block; margin: 0 auto 2.5rem;
+ animation: rise .6s ease-out both;
+}
+@keyframes rise { from { opacity: 0; transform: translateY(.5rem); } to { opacity: 1; transform: none; } }
+
+.kicker {
+ margin: 0 0 1rem;
+ font-size: .8125rem; letter-spacing: .22em; text-transform: uppercase;
+ color: var(--ink-2);
+}
+
+.display {
+ font-family: var(--disp);
+ font-weight: 700;
+ font-size: clamp(2.4rem, 1.4rem + 4.4vw, 4.6rem);
+ line-height: 1.02;
+ letter-spacing: -.03em;
+ margin: 0 0 1.5rem;
+}
+.display em {
+ font-style: normal;
+ background: linear-gradient(100deg, var(--pink), var(--cyan));
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+}
+
+.lede {
+ max-width: 46rem; margin: 0 auto 2.5rem;
+ color: var(--ink-2); font-size: 1.0625rem;
+}
+
+.installs { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; }
+.install { text-align: left; }
+.install-label {
+ display: block; font-size: .75rem; letter-spacing: .14em; text-transform: uppercase;
+ color: var(--ink-2); margin-bottom: .4rem;
+}
+
+.cmd {
+ display: block;
+ font-family: var(--mono); font-size: .9375rem;
+ background: var(--panel); border: 1px solid var(--line);
+ padding: .7rem 1rem; border-radius: 4px;
+ color: var(--ink); white-space: nowrap; overflow-x: auto;
+}
+.cmd::before { content: "$ "; color: var(--dim); }
+.cmd[data-copy] { cursor: pointer; }
+.cmd[data-copy]:hover { border-color: var(--sky); }
+.cmd.is-copied { border-color: var(--playing); }
+.cmd.is-copied::after { content: " copied"; color: var(--playing); }
+
+.hero-meta { margin-top: 2rem; color: var(--ink-2); font-size: .875rem; }
+
+/* ── Screenshots ──────────────────────────────────────────────────── */
+
+.shot { margin: 0; }
+.shot img {
+ display: block; width: 100%; height: auto;
+ border: 1px solid var(--line); border-radius: 6px;
+}
+.shot-hero {
+ width: min(100% - 2.5rem, 84rem);
+ margin: clamp(3rem, 7vw, 5rem) auto 0;
+}
+.shot-hero img { box-shadow: 0 2rem 5rem -1.5rem #00000099; }
+
+/* ── Bands ────────────────────────────────────────────────────────── */
+
+.band { padding-block: clamp(4rem, 9vw, 7rem); }
+.band-alt { background: #0D0725; border-block: 1px solid var(--line); }
+.band-close { padding-bottom: clamp(3rem, 6vw, 4.5rem); }
+
+.sec-title {
+ font-family: var(--disp); font-weight: 600;
+ font-size: clamp(1.7rem, 1.2rem + 1.9vw, 2.6rem);
+ line-height: 1.1; letter-spacing: -.02em; margin: 0 0 1rem;
+}
+.sec-lede { max-width: 44rem; color: var(--ink-2); margin: 0 0 2.5rem; }
+
+/* ── Features ─────────────────────────────────────────────────────── */
+
+.features {
+ list-style: none; margin: 0; padding: 0;
+ display: grid; gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+}
+.feature {
+ background: var(--panel);
+ border: 1px solid var(--line); border-left: 2px solid var(--accent);
+ border-radius: 5px;
+ padding: 1.4rem 1.4rem 1.6rem;
+}
+.feature-name {
+ display: inline-block; margin-bottom: .6rem;
+ color: var(--accent); font-weight: 700; font-size: .9375rem;
+ letter-spacing: .04em;
+}
+.feature-body { margin: 0; color: var(--ink-2); font-size: .9375rem; }
+.feature-body code {
+ color: var(--sky); background: #1B1140;
+ padding: .05em .3em; border-radius: 3px; font-size: .875em;
+}
+.feature-body em { font-style: italic; color: var(--ink); }
+
+/* ── Numbers ──────────────────────────────────────────────────────── */
+
+.numbers {
+ margin: 0; display: grid; gap: 2.5rem;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+}
+.number { border-top: 2px solid var(--violet); padding-top: 1.25rem; }
+.number-value {
+ font-family: var(--disp); font-weight: 700;
+ font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.2rem);
+ line-height: 1; letter-spacing: -.03em; margin: 0 0 .5rem;
+}
+.number-label { margin: 0 0 .75rem; color: var(--ink); font-size: .9375rem; }
+.number-note { margin: 0; color: var(--ink-2); font-size: .875rem; }
+
+/* ── Split sections ───────────────────────────────────────────────── */
+
+.split { display: grid; gap: clamp(2rem, 5vw, 3.5rem); align-items: center; }
+@media (min-width: 56rem) {
+ .split { grid-template-columns: 22rem 1fr; }
+ .split-reverse .split-text { order: 2; }
+}
+
+.filters { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
+.filters code {
+ display: inline-block; background: var(--panel); border: 1px solid var(--line);
+ padding: .35rem .7rem; border-radius: 4px; font-size: .875rem; color: var(--sky);
+}
+
+.keys { list-style: none; margin: 0; padding: 0; display: grid; gap: .55rem; }
+.keys li { display: grid; grid-template-columns: 6.5rem 1fr; align-items: baseline; gap: .75rem; }
+.keys span { color: var(--ink-2); font-size: .9375rem; }
+
+kbd {
+ font-family: var(--mono); font-size: .8125rem; font-weight: 700;
+ background: var(--panel); border: 1px solid var(--line); border-bottom-width: 2px;
+ border-radius: 4px; padding: .2rem .5rem; color: var(--sky);
+ display: inline-block; text-align: center;
+}
+
+/* ── Requirements ─────────────────────────────────────────────────── */
+
+.reqs { display: grid; gap: 2.5rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); }
+.req h3 { font-family: var(--disp); font-size: 1.25rem; font-weight: 600; margin: 0 0 .75rem; }
+.req p { margin: 0 0 1rem; color: var(--ink-2); font-size: .9375rem; }
+.req code { background: var(--panel); padding: .05em .35em; border-radius: 3px; color: var(--ink); }
+.req-note { font-size: .8125rem; }
+
+.protocols { border-collapse: collapse; width: 100%; margin-bottom: 1rem; font-size: .875rem; }
+.protocols th, .protocols td { text-align: left; padding: .5rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
+.protocols th { font-weight: 700; color: var(--sky); white-space: nowrap; padding-right: 1.25rem; }
+.protocols td { color: var(--ink-2); }
+
+/* ── Install grid ─────────────────────────────────────────────────── */
+
+.install-grid {
+ display: grid; gap: 1rem; margin-bottom: 2rem;
+ grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
+}
+
+/* ── Footer ───────────────────────────────────────────────────────── */
+
+.foot { border-top: 1px solid var(--line); padding-block: 2.5rem; }
+.foot-in { display: grid; gap: 1rem; }
+.foot-name {
+ margin: 0; font-weight: 700; letter-spacing: .1em;
+ background: linear-gradient(100deg, var(--pink), var(--cyan));
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+ width: max-content;
+}
+.foot-links { display: flex; flex-wrap: wrap; gap: 1.25rem; font-size: .875rem; }
+.foot-fine { margin: 0; color: var(--ink-2); font-size: .8125rem; }
+
+/* ── The signature: ommp's status bar, tracking the page ──────────── */
+
+.statusbar {
+ position: fixed; inset: auto 0 0 0; z-index: 50;
+ height: var(--sb-h);
+ background: #0A051EF2;
+ backdrop-filter: blur(8px);
+ border-top: 1px solid var(--line);
+ font-size: .8125rem;
+}
+.sb-fill {
+ height: 2px; width: 0%;
+ background: var(--violet);
+ transition: width .1s linear;
+}
+.sb-row {
+ height: calc(var(--sb-h) - 2px);
+ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
+ gap: 1rem;
+ width: min(100% - 2.5rem, var(--wrap)); margin-inline: auto;
+}
+.sb-state { color: var(--playing); font-weight: 700; white-space: nowrap; }
+.sb-track {
+ color: var(--sky); text-align: center;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.sb-time { color: var(--ink-2); text-align: right; white-space: nowrap; }
+
+@media (max-width: 34rem) {
+ .sb-row { grid-template-columns: auto 1fr; }
+ .sb-state { display: none; }
+ .sb-time { font-size: .75rem; }
+}
diff --git a/site/static/fonts/bricolage-grotesque-var.woff2 b/site/static/fonts/bricolage-grotesque-var.woff2
Binary files differ.
diff --git a/site/static/fonts/jetbrains-mono-400.woff2 b/site/static/fonts/jetbrains-mono-400.woff2
Binary files differ.
diff --git a/site/static/fonts/jetbrains-mono-700.woff2 b/site/static/fonts/jetbrains-mono-700.woff2
Binary files differ.
diff --git a/site/static/img/01-main.png b/site/static/img/01-main.png
Binary files differ.
diff --git a/site/static/img/02-help.png b/site/static/img/02-help.png
Binary files differ.
diff --git a/site/static/img/03-search.png b/site/static/img/03-search.png
Binary files differ.
diff --git a/site/static/img/logo.png b/site/static/img/logo.png
Binary files differ.
diff --git a/site/static/js/status.js b/site/static/js/status.js
@@ -0,0 +1,61 @@
+// The bar pinned to the bottom of the page is ommp's own status bar. It reads
+// the page the way the player reads a track: how far in you are, and what is
+// playing right now. Nothing here is required to read the page — if this file
+// never loads, the bar simply sits at 0:00.
+(function () {
+ "use strict";
+
+ var fill = document.querySelector(".sb-fill");
+ var track = document.querySelector(".sb-track");
+ var elapsed = document.querySelector(".sb-elapsed");
+ if (!fill || !track || !elapsed) return;
+
+ var TOTAL = 237; // 3:57, the length of the track in the screenshot
+ var sections = Array.prototype.slice.call(document.querySelectorAll("[data-section]"));
+
+ function clock(seconds) {
+ var s = Math.round(seconds);
+ return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
+ }
+
+ function update() {
+ var scrollable = document.documentElement.scrollHeight - window.innerHeight;
+ var progress = scrollable > 0 ? Math.min(1, Math.max(0, window.scrollY / scrollable)) : 0;
+
+ fill.style.width = (progress * 100).toFixed(2) + "%";
+ elapsed.textContent = clock(progress * TOTAL);
+
+ // Whichever labelled section has most recently crossed the middle of the
+ // viewport is the one you are reading.
+ var middle = window.innerHeight / 2;
+ var current = "ommp";
+ for (var i = 0; i < sections.length; i++) {
+ if (sections[i].getBoundingClientRect().top <= middle) {
+ current = sections[i].getAttribute("data-section");
+ }
+ }
+ if (track.textContent !== current) track.textContent = current;
+ }
+
+ var queued = false;
+ function onScroll() {
+ if (queued) return;
+ queued = true;
+ requestAnimationFrame(function () { queued = false; update(); });
+ }
+
+ addEventListener("scroll", onScroll, { passive: true });
+ addEventListener("resize", onScroll);
+ update();
+
+ // Click a command to copy it — the page is mostly commands.
+ document.querySelectorAll("[data-copy]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ if (!navigator.clipboard) return;
+ navigator.clipboard.writeText(el.textContent.trim()).then(function () {
+ el.classList.add("is-copied");
+ setTimeout(function () { el.classList.remove("is-copied"); }, 1400);
+ });
+ });
+ });
+})();
diff --git a/site/templates/landing.html b/site/templates/landing.html
@@ -0,0 +1,211 @@
+{%- set x = page.extra -%}
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ site.title }} — {{ site.description }}</title>
+<meta name="description" content="{{ page.description }}">
+<meta name="color-scheme" content="dark">
+<meta name="theme-color" content="#0A051E">
+
+<meta property="og:type" content="website">
+<meta property="og:title" content="{{ site.title }} — Oh My Music Player">
+<meta property="og:description" content="{{ page.description }}">
+<meta property="og:url" content="{{ page.permalink }}">
+<meta property="og:image" content="{{ site.origin }}{{ asset('img/01-main.png') }}">
+<meta name="twitter:card" content="summary_large_image">
+
+<link rel="icon" href="{{ asset('img/logo.png') }}">
+<link rel="preload" as="font" type="font/woff2" href="{{ asset('fonts/jetbrains-mono-400.woff2') }}" crossorigin>
+<link rel="preload" as="font" type="font/woff2" href="{{ asset('fonts/bricolage-grotesque-var.woff2') }}" crossorigin>
+<link rel="stylesheet" href="{{ asset('css/main.css') }}">
+</head>
+<body>
+
+<a class="skip" href="#what">Skip to content</a>
+
+<!-- ── Hero ──────────────────────────────────────────────────────────── -->
+<header class="hero">
+ <div class="wrap hero-in">
+ <img class="hero-logo" src="{{ asset('img/logo.png') }}" alt="ommp" width="723" height="421">
+
+ <p class="kicker">{{ x.kicker }}</p>
+ <h1 class="display">
+ {{ x.headline }}<br><em>{{ x.headline2 }}</em>
+ </h1>
+ <p class="lede">{{ x.lede }}</p>
+
+ <div class="installs">
+ <div class="install">
+ <span class="install-label">{{ x.install_primary_label }}</span>
+ <code class="cmd" data-copy>{{ x.install_primary }}</code>
+ </div>
+ <div class="install">
+ <span class="install-label">{{ x.install_secondary_label }}</span>
+ <code class="cmd" data-copy>{{ x.install_secondary }}</code>
+ </div>
+ </div>
+
+ <p class="hero-meta">
+ Linux · Apache 2.0 ·
+ <a href="https://github.com/sqzer-x/ommp">Source</a> ·
+ <a href="https://crates.io/crates/ommp">crates.io</a> ·
+ <a href="https://aur.archlinux.org/packages/ommp">AUR</a>
+ </p>
+ </div>
+
+ <figure class="shot shot-hero">
+ <img src="{{ asset('img/01-main.png') }}" alt="ommp playing a track: library on the left, queue in the middle, album art and track details on the right"
+ width="2400" height="1350" fetchpriority="high">
+ </figure>
+</header>
+
+<!-- ── Features, keyed to the tabs ───────────────────────────────────── -->
+<section id="what" class="band" data-section="Seven ways in">
+ <div class="wrap">
+ <h2 class="sec-title">Seven ways into the same library</h2>
+ <p class="sec-lede">The tab strip across the top is the whole navigation model. Each one is a different question about the same files.</p>
+
+ <ul class="features">
+ {%- for f in x.features %}
+ <li class="feature" style="--accent: {{ f.color }}">
+ <span class="feature-name">{{ f.name }}</span>
+ <p class="feature-body">{{ f.body | safe }}</p>
+ </li>
+ {%- endfor %}
+ </ul>
+ </div>
+</section>
+
+<!-- ── Speed ─────────────────────────────────────────────────────────── -->
+<section class="band band-alt" data-section="Speed">
+ <div class="wrap">
+ <h2 class="sec-title">Fast enough to forget about</h2>
+ <p class="sec-lede">Measured on this machine, not estimated. A library that takes a moment to open is a library you stop opening.</p>
+
+ <dl class="numbers">
+ {%- for n in x.numbers %}
+ <div class="number">
+ <dt class="number-value">{{ n.value }}</dt>
+ <dd class="number-label">{{ n.label }}</dd>
+ <dd class="number-note">{{ n.note }}</dd>
+ </div>
+ {%- endfor %}
+ </dl>
+ </div>
+</section>
+
+<!-- ── Search ────────────────────────────────────────────────────────── -->
+<section class="band" data-section="Search">
+ <div class="wrap split">
+ <div class="split-text">
+ <h2 class="sec-title">Find it while you type</h2>
+ <p class="sec-lede">Results narrow on every keystroke. Narrow further with a field, or by format.</p>
+ <ul class="filters">
+ <li><code>artist:yorushika</code></li>
+ <li><code>album:elma</code></li>
+ <li><code>genre:rock</code></li>
+ <li><code>*.flac</code></li>
+ </ul>
+ </div>
+ <figure class="shot">
+ <img src="{{ asset('img/03-search.png') }}" alt="The search overlay filtering a library down to fifty of fifty-two tracks"
+ width="2400" height="1350" loading="lazy">
+ </figure>
+ </div>
+</section>
+
+<!-- ── Keys ──────────────────────────────────────────────────────────── -->
+<section class="band band-alt" data-section="Keys">
+ <div class="wrap split split-reverse">
+ <div class="split-text">
+ <h2 class="sec-title">Keys where you expect them</h2>
+ <p class="sec-lede">Vim motions for the lists, media keys for the music. The mouse works too — click a tab, double-click a track, <kbd>Ctrl</kbd>-drag a border to resize.</p>
+ <ul class="keys">
+ {%- for k in x.keys %}
+ <li><kbd>{{ k.key }}</kbd><span>{{ k.desc }}</span></li>
+ {%- endfor %}
+ </ul>
+ </div>
+ <figure class="shot">
+ <img src="{{ asset('img/02-help.png') }}" alt="The keybinding overlay, two columns covering playback and navigation"
+ width="2400" height="1350" loading="lazy">
+ </figure>
+ </div>
+</section>
+
+<!-- ── Requirements ──────────────────────────────────────────────────── -->
+<section class="band" data-section="Before you start">
+ <div class="wrap">
+ <h2 class="sec-title">Two things to check first</h2>
+
+ <div class="reqs">
+ <div class="req">
+ <h3>A Nerd Font</h3>
+ <p>The interface is built from <a href="https://www.nerdfonts.com/">Nerd Font</a> icons. Without one they render as empty boxes. Install <code>JetBrainsMono Nerd Font</code> or similar and set it as your terminal font.</p>
+ </div>
+ <div class="req">
+ <h3>A graphics protocol, for album art</h3>
+ <p>Cover art is read out of the audio file itself and drawn as a real image where the terminal supports it. ommp detects which protocol is available at startup — there is nothing to configure.</p>
+ <table class="protocols">
+ <tbody>
+ {%- for p in x.protocols %}
+ <tr><th scope="row">{{ p.name }}</th><td>{{ p.terms }}</td></tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+ <p class="req-note">Anything else falls back to block characters, which still shows the artwork — just coarser. GPU acceleration is unrelated: Alacritty is accelerated and supports none of these; xterm is not and supports Sixel.</p>
+ </div>
+ </div>
+ </div>
+</section>
+
+<!-- ── Install ───────────────────────────────────────────────────────── -->
+<section class="band band-close" data-section="Install">
+ <div class="wrap">
+ <h2 class="sec-title">Install</h2>
+ <div class="install-grid">
+ <div class="install-card">
+ <span class="install-label">Arch Linux</span>
+ <code class="cmd" data-copy>yay -S ommp</code>
+ </div>
+ <div class="install-card">
+ <span class="install-label">With Rust installed</span>
+ <code class="cmd" data-copy>cargo install ommp</code>
+ </div>
+ <div class="install-card">
+ <span class="install-label">From source</span>
+ <code class="cmd" data-copy>git clone https://github.com/sqzer-x/ommp</code>
+ </div>
+ </div>
+ <p class="sec-lede">Then put some music in <code>~/Music</code> and run <code>ommp</code>.</p>
+ </div>
+</section>
+
+<footer class="foot">
+ <div class="wrap foot-in">
+ <p class="foot-name">ommp</p>
+ <nav class="foot-links">
+ <a href="https://github.com/sqzer-x/ommp">GitHub</a>
+ <a href="https://crates.io/crates/ommp">crates.io</a>
+ <a href="https://aur.archlinux.org/packages/ommp">AUR</a>
+ <a href="https://github.com/sqzer-x/ommp/blob/main/LICENSE">Apache 2.0</a>
+ </nav>
+ <p class="foot-fine">Built with <a href="https://github.com/sqzer-x/sqzass">sqzass</a>. Copyright 2026 sqzer (hyukjin an).</p>
+ </div>
+</footer>
+
+<!-- ── The app's own status bar, tracking the page ───────────────────── -->
+<div class="statusbar" aria-hidden="true">
+ <div class="sb-fill"></div>
+ <div class="sb-row">
+ <span class="sb-state">▶ Playing</span>
+ <span class="sb-track">ommp</span>
+ <span class="sb-time"><span class="sb-elapsed">0:00</span> / 3:57</span>
+ </div>
+</div>
+
+<script src="{{ asset('js/status.js') }}" defer></script>
+</body>
+</html>
diff --git a/site/templates/page.html b/site/templates/page.html
@@ -0,0 +1,19 @@
+{#- Fallback template. sqzass falls back to page.html for anything without an
+ explicit `template` in its front matter, and errors out if it is missing.
+ The site is one page, which uses landing.html, so this is a safety net. -#}
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ page.title }} — {{ site.title }}</title>
+<meta name="description" content="{{ page.description }}">
+<link rel="stylesheet" href="{{ asset('css/main.css') }}">
+</head>
+<body>
+<main class="wrap prose">
+ <h1>{{ page.title }}</h1>
+ {{ page.content | safe }}
+</main>
+</body>
+</html>
diff --git a/site/tools/prep-state.py b/site/tools/prep-state.py
@@ -0,0 +1,9 @@
+"""Shoot-only player settings; the caller restores the originals afterwards."""
+import json, sys
+p = sys.argv[1]
+d = json.load(open(p))
+d["volume"] = 0.72 # a realistic meter; silence comes from muting the stream
+d["pane_widths"] = [20, 50, 30] # wider right column: track info stops wrapping
+d["right_split"] = 46 # a little more room for the info fields
+d["info_view"] = "AlbumArt"
+json.dump(d, open(p, "w"), indent=2)
diff --git a/site/tools/shoot.sh b/site/tools/shoot.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Capture ommp screenshots for the landing page.
+#
+# Runs the real binary in a real terminal: album art only renders as an image
+# under a graphics protocol, and Ghostty is the only installed terminal that
+# speaks one. Anything else would show the block-character fallback.
+#
+# The window is fullscreen for about fifteen seconds and then closes. Your
+# player settings are backed up and restored — the app rewrites them on exit,
+# and this script deliberately changes a few for the shot.
+set -euo pipefail
+
+export XDG_RUNTIME_DIR=/run/user/1000
+export WAYLAND_DISPLAY=wayland-1
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+OUT=${OUT:-/tmp/ommp-shots}
+FONT_SIZE=${FONT_SIZE:-20}
+OMMP=${OMMP:-/home/himesama/Documents/ommp/target/release/ommp}
+MONITOR=${MONITOR:-DP-1}
+STATE="$HOME/.config/ommp/state.json"
+
+mkdir -p "$OUT"
+BAK="$OUT/state.json.bak"
+[ -f "$STATE" ] && [ ! -f "$BAK" ] && cp -a "$STATE" "$BAK"
+
+cleanup() {
+ pkill -x ommp 2>/dev/null || true
+ sleep 0.4
+ [ -f "$BAK" ] && cp -a "$BAK" "$STATE"
+}
+trap cleanup EXIT
+
+python3 "$HERE/prep-state.py" "$STATE"
+
+# --background matches the logo's #0A051E so padding is not a different black.
+# --palette pins the ANSI colours the app draws selections and the progress bar
+# with, so the shot does not depend on whatever theme happens to be configured.
+ghostty \
+ --font-family="JetBrainsMono Nerd Font" \
+ --font-size="$FONT_SIZE" \
+ --background=0A051E \
+ --foreground=E8E6F0 \
+ --palette=6=#00ffff --palette=14=#00ffff \
+ --palette=5=#c850ff --palette=13=#c850ff \
+ --fullscreen=true \
+ --window-decoration=none \
+ --window-padding-x=16 --window-padding-y=12 \
+ --confirm-close-surface=false \
+ -e "$OMMP" &
+
+# The splash runs a fixed two seconds; wait it out plus a beat for the first draw.
+sleep 5
+
+shoot() { grim -o "$MONITOR" "$OUT/$1.png" && echo " -> $1.png"; }
+
+# Mute the player's audio stream at the server, not in the app: the volume
+# meter is part of the shot, so it has to read a real level while staying
+# silent. The stream exists from startup, before anything plays.
+SINK_INPUT=""
+for _ in $(seq 20); do
+ SINK_INPUT=$(pactl -f json list sink-inputs 2>/dev/null \
+ | python3 -c 'import json,sys
+try:
+ for si in json.load(sys.stdin):
+ if si["properties"].get("application.process.binary") == "ommp":
+ print(si["index"]); break
+except Exception:
+ pass')
+ [ -n "$SINK_INPUT" ] && break
+ sleep 0.25
+done
+if [ -n "$SINK_INPUT" ]; then
+ pactl set-sink-input-mute "$SINK_INPUT" 1
+ echo " muted sink-input $SINK_INPUT"
+else
+ echo " WARNING: could not find the player audio stream; it will be audible" >&2
+fi
+
+# Start playback so the status bar reads Playing with a partly filled progress
+# bar, rather than Stopped at 0:00.
+wtype " "
+sleep 9
+
+shoot 01-main
+
+wtype -M ctrl -k h -m ctrl; sleep 0.8; shoot 02-help; wtype -k Escape; sleep 0.5
+wtype -M ctrl -k s -m ctrl; sleep 0.8; wtype -d 40 "yorushika"; sleep 1.0
+shoot 03-search; wtype -k Escape; sleep 0.5
+
+echo "done: $OUT"
diff --git a/src/ui/widgets/help_modal.rs b/src/ui/widgets/help_modal.rs
@@ -78,7 +78,7 @@ fn render_column(bindings: &[(&str, &str)], theme: &Theme) -> Vec<Line<'static>>
}
Line::from(vec![
Span::styled(
- format!(" {:16}", key),
+ format!(" {:18}", key),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
Span::styled(desc.to_string(), Style::default().fg(theme.fg)),
|