ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit 0a5cb58b1ddb3da82913743d863277534d2d6949
parent 47ab1a509bebdb9bf944215d73aacb89bf5be6be
Author: sqzer <sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 20:19:16 +0900

Reshoot the screenshots and move the site to ommp.sqzer.com

The help modal's key column was widened to 18 characters, which pushed the
longest row past the modal's inner width and clipped 'Focus prev / next pane'
and 'Info panel: clock / art'. Back to 16, with a shorter label for the one
binding that needed the extra space.

The wide shot was taken at font-size 20, where the info panel stops at 'Disc #'
and album names truncate mid-word. At 14 the full thirteen fields fit, sample
rate and bit depth included, and nothing is cut. The modal shots stay at 20:
a modal is a percentage of the terminal but its contents are a fixed number of
characters, so a wider terminal only adds empty space inside it.

Screenshots now ship at the captured 3840-wide scale instead of downsampled to
2400. Resampling terminal text both blurs the glyphs and inflates the PNG by
inventing intermediate colours — the 4K originals were smaller than the
downscaled ones. As AVIF the three come to 428K against 1.8M as PNG, with no
visible difference at 1:1. og:image gets its own 1200x630 PNG, which is the
size the card renders and the one format crawlers can all read.

The two modal close-ups moved out of the half-width split and under it at the
full width of the wrap. Squeezed beside the copy they were unreadable, which
defeated the point of showing them.

Also stops the clone URL scrolling out of sight inside its install card.

Diffstat:
Msite/content/_index.md | 4----
Msite/sqzass.toml | 7+++----
Asite/static/CNAME | 1+
Msite/static/css/main.css | 18+++++++++++++++---
Asite/static/img/01-main.avif | 0binary
Dsite/static/img/01-main.png | 0binary
Asite/static/img/02-help.avif | 0binary
Dsite/static/img/02-help.png | 0binary
Asite/static/img/03-search.avif | 0binary
Dsite/static/img/03-search.png | 0binary
Asite/static/img/og.png | 0binary
Msite/templates/landing.html | 38+++++++++++++++++++++-----------------
Msite/tools/deploy.sh | 2+-
Msite/tools/shoot.sh | 142+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/ui/widgets/help_modal.rs | 4++--
15 files changed, 124 insertion(+), 92 deletion(-)

diff --git a/site/content/_index.md b/site/content/_index.md
@@ -14,10 +14,6 @@ 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]]
diff --git a/site/sqzass.toml b/site/sqzass.toml
@@ -1,10 +1,9 @@
 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"
+# Custom domain, so there is no repository subpath to prefix. static/CNAME is
+# copied through verbatim and is what tells GitHub Pages to serve it here.
+base_url = "https://ommp.sqzer.com"
 
 [search]
 # One page. A full-text index nobody fetches is just bytes.
diff --git a/site/static/CNAME b/site/static/CNAME
@@ -0,0 +1 @@
+ommp.sqzer.com
diff --git a/site/static/css/main.css b/site/static/css/main.css
@@ -160,6 +160,7 @@ a:hover { color: var(--cyan); }
   margin: clamp(3rem, 7vw, 5rem) auto 0;
 }
 .shot-hero img { box-shadow: 0 2rem 5rem -1.5rem #00000099; }
+.shot-detail img { box-shadow: 0 1.5rem 4rem -2rem #00000099; }
 
 /* ── Bands ────────────────────────────────────────────────────────── */
 
@@ -216,11 +217,18 @@ a:hover { color: var(--cyan); }
 
 /* ── Split sections ───────────────────────────────────────────────── */
 
-.split { display: grid; gap: clamp(2rem, 5vw, 3.5rem); align-items: center; }
+/* Copy beside its list, and the screenshot underneath at the full width of the
+   wrap. These shots are close-ups of a modal whose contents are a fixed number
+   of characters — squeezed into a column beside the copy they are simply too
+   small to read, which defeats the point of showing them. */
+.split {
+  display: grid; gap: clamp(2rem, 5vw, 3.5rem); align-items: start;
+  margin-bottom: clamp(2.25rem, 4vw, 3rem);
+}
 @media (min-width: 56rem) {
-  .split { grid-template-columns: 22rem 1fr; }
-  .split-reverse .split-text { order: 2; }
+  .split { grid-template-columns: 1fr minmax(16rem, 22rem); }
 }
+.split-text .sec-lede { margin-bottom: 0; }
 
 .filters { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
 .filters code {
@@ -258,6 +266,10 @@ kbd {
   display: grid; gap: 1rem; margin-bottom: 2rem;
   grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
 }
+/* The clone URL is longer than a third of the wrap. Scrolling it inside the
+   card hides the end behind no affordance at all, so wrap it instead — the
+   whole string is what gets copied either way. */
+.install-card .cmd { white-space: normal; overflow-wrap: anywhere; }
 
 /* ── Footer ───────────────────────────────────────────────────────── */
 
diff --git a/site/static/img/01-main.avif b/site/static/img/01-main.avif
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.avif b/site/static/img/02-help.avif
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.avif b/site/static/img/03-search.avif
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/og.png b/site/static/img/og.png
Binary files differ.
diff --git a/site/templates/landing.html b/site/templates/landing.html
@@ -13,7 +13,7 @@
 <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 property="og:image" content="{{ site.origin }}{{ asset('img/og.png') }}">
 <meta name="twitter:card" content="summary_large_image">
 
 <link rel="icon" href="{{ asset('img/logo.png') }}">
@@ -56,8 +56,8 @@
   </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">
+    <img src="{{ asset('img/01-main.avif') }}" alt="ommp playing a track: library on the left, queue in the middle, album art and track details on the right"
+         width="3840" height="2160" fetchpriority="high">
   </figure>
 </header>
 
@@ -98,10 +98,12 @@
 
 <!-- ── 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>
+  <div class="wrap">
+    <div class="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>
+      </div>
       <ul class="filters">
         <li><code>artist:yorushika</code></li>
         <li><code>album:elma</code></li>
@@ -109,28 +111,30 @@
         <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 class="shot shot-detail">
+      <img src="{{ asset('img/03-search.avif') }}" alt="The search overlay filtering a library down to fifty of fifty-two tracks"
+           width="2400" height="1560" 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>
+  <div class="wrap">
+    <div class="split">
+      <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>
+      </div>
       <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 class="shot shot-detail">
+      <img src="{{ asset('img/02-help.avif') }}" alt="The keybinding overlay, two columns covering playback and navigation"
+           width="3100" height="1700" loading="lazy">
     </figure>
   </div>
 </section>
diff --git a/site/tools/deploy.sh b/site/tools/deploy.sh
@@ -28,6 +28,6 @@ if git -C "$WORK" diff --cached --quiet; then
 else
   git -C "$WORK" commit -q -m "Deploy the landing page"
   git -C "$WORK" push origin gh-pages
-  echo "published → https://sqzer-x.github.io/ommp/"
+  echo "published → https://ommp.sqzer.com/"
 fi
 git -C "$REPO" worktree remove "$WORK" --force
diff --git a/site/tools/shoot.sh b/site/tools/shoot.sh
@@ -1,91 +1,111 @@
 #!/usr/bin/env bash
-# Capture ommp screenshots for the landing page.
+# Capture ommp screenshots for the landing page, and crop them to what the
+# page actually shows.
 #
 # 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.
 #
+# Two font sizes, because the two kinds of shot want opposite things:
+#
+#   14  the wide shot. Everything fits — full album names, and all thirteen
+#       fields of the info panel including the sample rate and bit depth.
+#       At 20 the panel stops at "Disc #" and the best detail is lost.
+#   20  the modal close-ups. A modal is sized as a percentage of the terminal
+#       but its contents are a fixed number of characters, so a wider terminal
+#       just adds empty space inside it. 20 is where the columns sit tight.
+#
 # 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.
+# player settings and your default sink's mute state are backed up and
+# restored — the app rewrites the former on exit, and this script changes both.
 set -euo pipefail
 
-export XDG_RUNTIME_DIR=/run/user/1000
-export WAYLAND_DISPLAY=wayland-1
+export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/1000}
+export WAYLAND_DISPLAY=${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}
+RAW=${RAW:-$HOME/.cache/ommp-shoot/shots}
+OUT=${OUT:-$HERE/../static/img}
+OMMP=${OMMP:-$HERE/../../target/release/ommp}
 MONITOR=${MONITOR:-DP-1}
 STATE="$HOME/.config/ommp/state.json"
 
-mkdir -p "$OUT"
-BAK="$OUT/state.json.bak"
+mkdir -p "$RAW" "$OUT"
+BAK="$RAW/state.json.bak"
 [ -f "$STATE" ] && [ ! -f "$BAK" ] && cp -a "$STATE" "$BAK"
 
+SINK=$(pactl get-default-sink)
+SINK_WAS_MUTED=$(pactl get-sink-mute "$SINK" | grep -o 'yes\|no')
+
 cleanup() {
   pkill -x ommp 2>/dev/null || true
-  sleep 0.4
+  sleep 0.5
   [ -f "$BAK" ] && cp -a "$BAK" "$STATE"
+  [ "$SINK_WAS_MUTED" = yes ] || pactl set-sink-mute "$SINK" 0
 }
 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"; }
+# --palette pins the two ANSI colours the app draws selections and the progress
+# bar with, so the shot matches the page's palette rather than whatever theme
+# happens to be configured.
+launch() {
+  ghostty \
+    --font-family="JetBrainsMono Nerd Font" \
+    --font-size="$1" \
+    --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" >/dev/null 2>&1 &
+  # The splash runs a fixed two seconds; wait it out plus a beat for the draw.
+  sleep 5
+}
 
-# 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
+stop() { pkill -x ommp 2>/dev/null || true; sleep 1; }
 
-# Start playback so the status bar reads Playing with a partly filled progress
-# bar, rather than Stopped at 0:00.
+# ── The wide shot ─────────────────────────────────────────────────────────
+# Playback has to be running for the status bar to read Playing over a partly
+# filled progress bar. rodio does not create its sink-input until that moment,
+# so there is no per-stream target to mute in advance — mute the whole sink
+# across the shot instead and put it back afterwards.
+python3 "$HERE/prep-state.py" "$STATE"
+pactl set-sink-mute "$SINK" 1
+launch 14
 wtype " "
 sleep 9
+grim -o "$MONITOR" "$RAW/01-main.png"
+stop
+pactl set-sink-mute "$SINK" 0
 
-shoot 01-main
+# ── The modals ────────────────────────────────────────────────────────────
+# Nothing plays here, so nothing is audible.
+python3 "$HERE/prep-state.py" "$STATE"
+launch 20
+wtype -M ctrl -k h -m ctrl; sleep 1.2; grim -o "$MONITOR" "$RAW/02-help.png"
+wtype -k Escape; sleep 0.5
+wtype -M ctrl -k s -m ctrl; sleep 0.8; wtype -d 40 "yorushika"; sleep 1.2
+grim -o "$MONITOR" "$RAW/03-search.png"
+stop
 
-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
+# ── Crop and encode ───────────────────────────────────────────────────────
+# Kept at the captured 3840-wide scale rather than downsampled: the pixel grid
+# of terminal text is already sharp, and resampling it both blurs the glyphs
+# and *inflates* the PNG by inventing intermediate colours.
+#
+# The modals are cropped to themselves. Uncropped they are a small rectangle in
+# the middle of a 4K frame, and no amount of page width makes them readable.
+crop() { magick "$RAW/$1.png" -crop "$2" +repage -strip "$OUT/$3.png"; }
+
+magick "$RAW/01-main.png" -strip "$OUT/01-main.png"
+crop 02-help   3100x1700+380+250 02-help
+crop 03-search 2400x1560+720+300 03-search
 
-echo "done: $OUT"
+for f in "$OUT"/0*.png; do
+  printf "  %-16s %-11s %s\n" "$(basename "$f")" \
+    "$(magick identify -format '%wx%h' "$f")" "$(du -h "$f" | cut -f1)"
+done
diff --git a/src/ui/widgets/help_modal.rs b/src/ui/widgets/help_modal.rs
@@ -36,7 +36,7 @@ const NAVIGATION: &[(&str, &str)] = &[
     ("d", "Remove from queue"),
     ("c", "Clear queue"),
     ("", ""),
-    ("Ctrl+drag border", "Resize panels"),
+    ("Ctrl+drag", "Resize panels"),
     ("Double-click", "Play queue track"),
     ("", ""),
     ("Esc", "Close modal"),
@@ -78,7 +78,7 @@ fn render_column(bindings: &[(&str, &str)], theme: &Theme) -> Vec<Line<'static>>
             }
             Line::from(vec![
                 Span::styled(
-                    format!("  {:18}", key),
+                    format!("  {:16}", key),
                     Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
                 ),
                 Span::styled(desc.to_string(), Style::default().fg(theme.fg)),