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 8748f0017cdb9da8ff7730465e40d3e643fcc9d3
parent 3ce49f6d15fd49e599e790ec60f9c541fd5e956f
Author: sqzer <sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:08:24 +0900

Open the page on the splash screen, and put the screenshots back as they were

The hero fills with ommp's own boot screen. The splash scatters music notes
across a dark field while the library loads; tools/note-field.py reproduces that
scatter from the same hash the app uses, the same four glyphs and the same three
colours. A note falls where (7x + 13y) mod 37 == 0, and shifting either axis by
37 adds a multiple of 37 to the hash, so a 37x37 tile repeats without a seam.
It is rendered here rather than shipped as text because the glyphs live in the
CJK fonts, not in the page's latin-subset JetBrains Mono. The two radial washes
that stood in for it are still there, over the top.

The screenshots go back to whole frames. Cropping to the modals was meant to
make them readable and instead made them the loudest thing on the page. The
layout goes back with them: copy in a fixed column, screenshot in the rest,
alternating sides. Only the hero shot earns the full width — below it the
screenshots illustrate a point the copy already makes.

Both modal shots are now taken with playback running. One reading Stopped
beside two reading Playing looks like a mistake rather than a screenshot.

Diffstat:
Msite/static/css/main.css | 40++++++++++++++++++++++++++--------------
Msite/static/img/02-help.avif | 0binary
Msite/static/img/03-search.avif | 0binary
Asite/static/img/note-field.png | 0binary
Msite/templates/landing.html | 28++++++++++++----------------
Asite/tools/note-field.py | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msite/tools/shoot.sh | 33+++++++++++++++++++--------------
7 files changed, 137 insertion(+), 44 deletion(-)

diff --git a/site/static/css/main.css b/site/static/css/main.css
@@ -88,11 +88,27 @@ a:hover { color: var(--cyan); }
 .hero {
   position: relative;
   padding-block: clamp(3rem, 8vw, 6rem) 0;
-  /* The splash screen's scattered-note field, reduced to two soft washes. */
+  background-color: var(--bg);
+}
+
+/* The splash screen, as the page opens on. ommp scatters music notes across a
+   dark field while it loads (about_modal.rs, render_splash_screen); the tile is
+   that same scatter, generated from the same hash by tools/note-field.py. It
+   fades out before the screenshot so the two are never competing. */
+.hero::before {
+  content: "";
+  position: absolute; inset: 0; z-index: 0;
+  pointer-events: none;
   background:
-    radial-gradient(60rem 32rem at 18% -8%, #3C145055, transparent 70%),
-    radial-gradient(52rem 30rem at 88% 4%,  #143C5055, transparent 70%);
+    radial-gradient(60rem 32rem at 18% -8%, #3C145066, transparent 70%),
+    radial-gradient(52rem 30rem at 88% 4%,  #143C5066, transparent 70%),
+    url("../img/note-field.png") repeat;
+  background-size: auto, auto, 370px 740px;
+  -webkit-mask-image: linear-gradient(#000 48%, #0000 88%);
+          mask-image: linear-gradient(#000 48%, #0000 88%);
 }
+.hero-in, .shot-hero { position: relative; z-index: 1; }
+
 .hero-in { text-align: center; }
 
 .hero-logo {
@@ -160,7 +176,6 @@ 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 ────────────────────────────────────────────────────────── */
 
@@ -217,18 +232,15 @@ a:hover { color: var(--cyan); }
 
 /* ── Split sections ───────────────────────────────────────────────── */
 
-/* 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);
-}
+/* Copy in a fixed column, screenshot in the rest. The hero shot is the one that
+   earns full width; from here down the screenshots illustrate a point the copy
+   is already making, and blowing them up to fill the page only pushes the
+   reader further from it. */
+.split { display: grid; gap: clamp(2rem, 5vw, 3.5rem); align-items: center; }
 @media (min-width: 56rem) {
-  .split { grid-template-columns: 1fr minmax(16rem, 22rem); }
+  .split { grid-template-columns: 22rem 1fr; }
+  .split-reverse .split-text { order: 2; }
 }
-.split-text .sec-lede { margin-bottom: 0; }
 
 .filters { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
 .filters code {
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/03-search.avif b/site/static/img/03-search.avif
Binary files differ.
diff --git a/site/static/img/note-field.png b/site/static/img/note-field.png
Binary files differ.
diff --git a/site/templates/landing.html b/site/templates/landing.html
@@ -101,12 +101,10 @@
 
 <!-- ── Search ────────────────────────────────────────────────────────── -->
 <section class="band" data-section="Search">
-  <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>
+  <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>
@@ -114,30 +112,28 @@
         <li><code>*.flac</code></li>
       </ul>
     </div>
-    <figure class="shot shot-detail">
+    <figure class="shot">
       <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">
+           width="3840" height="2160" loading="lazy">
     </figure>
   </div>
 </section>
 
 <!-- ── Keys ──────────────────────────────────────────────────────────── -->
 <section class="band band-alt" data-section="Keys">
-  <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>
+  <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 shot-detail">
+    <figure class="shot">
       <img src="{{ asset('img/02-help.avif') }}" alt="The keybinding overlay, two columns covering playback and navigation"
-           width="3100" height="1700" loading="lazy">
+           width="3840" height="2160" loading="lazy">
     </figure>
   </div>
 </section>
diff --git a/site/tools/note-field.py b/site/tools/note-field.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Generate the hero's background: ommp's splash-screen note field.
+
+The splash fills the terminal with dark and scatters music notes across it
+(src/ui/widgets/about_modal.rs, render_splash_screen). This reproduces that
+exact scatter — the same hash, the same four glyphs, the same three colours —
+as a tile the page can repeat.
+
+The scatter is periodic. A note falls on cell (x, y) when
+
+    (7x + 13y) mod 37 == 0
+
+and shifting x or y by 37 adds 7*37 or 13*37 to the hash, both multiples of 37.
+So a 37x37 tile repeats seamlessly. Which glyph and which colour a note gets
+depends on hash/37, whose period is 444 rather than 37, so those cycle sooner
+here than in the app. At this contrast — the brightest of the three is #3C1450
+on #0A051E — that is not a difference anyone can see.
+
+Rendered to a raster on this machine rather than shipped as text: the glyphs
+live in the CJK fonts, not in the page's latin-subset JetBrains Mono, and a
+visitor without them would get four kinds of tofu.
+"""
+
+import subprocess
+import sys
+
+# src/ui/widgets/about_modal.rs
+NOTES = ["♪", "♫", "♩", "◆"]
+DIM_COLORS = ["#3C1450", "#143C50", "#501432"]  # DIM_PURPLE, DIM_CYAN, DIM_PINK
+PERIOD = 37
+
+# One terminal cell, at 2x so the tile stays sharp on a hidpi screen. The 1:2
+# ratio is roughly what a monospace cell is.
+CELL_W, CELL_H = 20, 40
+FONT_SIZE = 26
+
+OUT = sys.argv[1] if len(sys.argv) > 1 else "static/img/note-field.png"
+
+
+def font():
+    """A font with all four glyphs. Prefer the one the terminal itself falls
+    back to for U+266A, so the page's notes are the shapes ommp actually draws."""
+    listing = subprocess.run(
+        ["fc-list", ":charset=266a 266b 2669 25c6", "file"],
+        capture_output=True, text=True, check=True,
+    ).stdout
+    files = [l.split(":")[0].strip() for l in listing.splitlines() if l.strip()]
+    for preferred in ("NotoSansMonoCJK", "NotoSansCJK", "DejaVuSans"):
+        for f in files:
+            if preferred in f:
+                return f
+    if not files:
+        sys.exit("no font on this system has all four glyphs")
+    return files[0]
+
+
+def main():
+    args = ["magick", "-size", f"{PERIOD * CELL_W}x{PERIOD * CELL_H}", "xc:none",
+            "-font", font(), "-pointsize", str(FONT_SIZE), "-gravity", "none"]
+
+    drawn = 0
+    for y in range(PERIOD):
+        for x in range(PERIOD):
+            h = (x * 7 + y * 13) & 0xFFFFFFFF
+            if h % PERIOD:
+                continue
+            glyph = NOTES[(h // PERIOD) % len(NOTES)]
+            colour = DIM_COLORS[(h // PERIOD + 1) % len(DIM_COLORS)]
+            # annotate's origin is the baseline; sit the glyph in its cell.
+            args += ["-fill", colour,
+                     "-annotate", f"+{x * CELL_W + 2}+{y * CELL_H + CELL_H - 12}", glyph]
+            drawn += 1
+
+    args += ["-strip", OUT]
+    subprocess.run(args, check=True)
+    print(f"  {OUT}: {drawn} notes in a {PERIOD}x{PERIOD} cell tile")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/site/tools/shoot.sh b/site/tools/shoot.sh
@@ -1,6 +1,5 @@
 #!/usr/bin/env bash
-# Capture ommp screenshots for the landing page, and crop them to what the
-# page actually shows.
+# Capture the ommp screenshots the landing page uses.
 #
 # 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
@@ -11,9 +10,10 @@
 #   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.
+#   20  the modal shots. 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, and where
+#       the keybindings still read at the size the page shows them.
 #
 # The window is fullscreen for about fifteen seconds and then closes. Your
 # player settings and your default sink's mute state are backed up and
@@ -83,29 +83,34 @@ stop
 pactl set-sink-mute "$SINK" 0
 
 # ── The modals ────────────────────────────────────────────────────────────
-# Nothing plays here, so nothing is audible.
+# Playing here too: the status bar shows through beside every modal, and a shot
+# that reads Stopped next to two that read Playing looks like a mistake.
 python3 "$HERE/prep-state.py" "$STATE"
+pactl set-sink-mute "$SINK" 1
 launch 20
+wtype " "
+sleep 8
 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
+pactl set-sink-mute "$SINK" 0
 
 # ── 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
+# Whole frames, not crops: the page shows each screenshot beside the copy that
+# explains it, at a size where the full window still reads.
+for n in 01-main 02-help 03-search; do
+  magick "$RAW/$n.png" -strip "$RAW/$n-stripped.png"
+  avifenc -q 84 -s 0 "$RAW/$n-stripped.png" "$OUT/$n.avif" >/dev/null
+  rm -f "$RAW/$n-stripped.png"
+done
 
-for f in "$OUT"/0*.png; do
+for f in "$OUT"/0*.avif; do
   printf "  %-16s %-11s %s\n" "$(basename "$f")" \
     "$(magick identify -format '%wx%h' "$f")" "$(du -h "$f" | cut -f1)"
 done