Add audio player with timestamp seeking
Sticky audio element at the bottom of episode pages. Clicking a
timestamp seeks the player to that point and starts playback.

Assisted-by: Claude Opus 4.6 via pi
change qrozykrryvvvymvlsqlmwmsomoylovkn
commit 7a9fdc7ab2e52b8c14cbd0bdcd0ec13f6a1069ab
author Alpha Chen <alpha@kejadlen.dev>
date
parent zzxrqmnv
diff --git a/Rakefile b/Rakefile
index 0b54994..d47f72e 100644
--- a/Rakefile
+++ b/Rakefile
@@ -139,6 +139,7 @@ task :pages do
         number: et.number,
         title: et.episode.title.sub(/^Episode #{et.number}:\s+/, ""),
         slug: et.slug,
+        audio_url: et.episode.audio_url,
         text: File.read(et.text_path)
       }
     end
diff --git a/lib/pages/episode.html.erb b/lib/pages/episode.html.erb
index 12bde64..5f39cdb 100644
--- a/lib/pages/episode.html.erb
+++ b/lib/pages/episode.html.erb
@@ -6,15 +6,19 @@
 <title><%= CGI.escapeHTML("#{ep[:number]}. #{ep[:title]}") %> — Cooking Issues</title>
 <style>
   * { box-sizing: border-box; margin: 0; padding: 0; }
-  body { font-family: system-ui, sans-serif; line-height: 1.6; color: #222; max-width: 50rem; margin: 0 auto; padding: 1rem; }
+  body { font-family: system-ui, sans-serif; line-height: 1.6; color: #222; max-width: 50rem; margin: 0 auto; padding: 1rem; padding-bottom: 5rem; }
   h1 { font-size: 1.3rem; margin-bottom: 0.5rem; }
   .back { display: inline-block; margin-bottom: 1rem; color: #0066cc; text-decoration: none; }
   .back:hover { text-decoration: underline; }
   p { margin-bottom: 1em; }
   h6.timestamp { color: #888; font-size: 0.85rem; font-family: monospace; font-weight: normal; margin-bottom: 0.25em; }
+  h6.timestamp a { color: inherit; text-decoration: none; }
+  h6.timestamp a:hover { text-decoration: underline; color: #0066cc; }
   h6.timestamp + p { margin-top: 0; }
   :target { background: #fff3a8; }
   [data-pagefind-highlight] { background: #fff3a8; padding: 0 2px; border-radius: 2px; }
+  .player { position: sticky; bottom: 0; background: #f8f8f8; border-top: 1px solid #ddd; padding: 0.5rem 0; margin: 0 -1rem; padding-left: 1rem; padding-right: 1rem; }
+  .player audio { width: 100%; }
 </style>
 </head>
 <body>
@@ -25,14 +29,38 @@
 <% ep[:text].split("\n\n").each_with_index do |para, i| %>
   <% if (m = para.match(/\A\[([^\]]+)\]\s*(.*)/)) %>
     <% anchor = m[1].gsub(/[^0-9:]/, "").gsub(":", "-") %>
-    <h6 id="t-<%= anchor %>" class="timestamp">[<%= CGI.escapeHTML(m[1]) %>]</h6>
+    <% time = m[1].gsub(/[^0-9:]/, "") %>
+    <h6 id="t-<%= anchor %>" class="timestamp"><a href="#t-<%= anchor %>" data-seek="<%= time %>">[<%= CGI.escapeHTML(m[1]) %>]</a></h6>
     <p><%= CGI.escapeHTML(m[2]) %></p>
   <% else %>
     <p><%= CGI.escapeHTML(para) %></p>
   <% end %>
 <% end %>
 
+<div class="player">
+  <audio id="audio" controls preload="none" src="<%= CGI.escapeHTML(ep[:audio_url]) %>"></audio>
+</div>
+
 <script type="module">
+  const audio = document.getElementById("audio");
+
+  function parseTimestamp(ts) {
+    const parts = ts.split(":").map(Number);
+    if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
+    if (parts.length === 2) return parts[0] * 60 + parts[1];
+    return parts[0];
+  }
+
+  document.addEventListener("click", (e) => {
+    const link = e.target.closest("[data-seek]");
+    if (!link) return;
+    e.preventDefault();
+    const seconds = parseTimestamp(link.dataset.seek);
+    audio.currentTime = seconds;
+    audio.play();
+    history.replaceState(null, "", link.getAttribute("href"));
+  });
+
   await import("./pagefind/pagefind-highlight.js");
   new PagefindHighlight({ highlightParam: "highlight" });
 </script>