Fix mobile/UX bug cluster: video minimize, audio interrupt, scroll reset, redundant box

Four fixes in public/index.html, all reported against recaps.cc on mobile:

- Video minimize no longer shows a black frame on expand. toggleVideoMinimize()
  used to call render(), rebuilding the YouTube iframe inside the display:none
  minimized container, which wedged the IFrame API. Minimize now toggles the
  .results-left.minimized class in place; a !videoMinimized guard on render()'s
  needsMount plus a new ensureYtMounted() (called from the expand paths) keep the
  player from ever being created in a hidden container.

- Background processing no longer interrupts podcast audio or resets the
  transcript scroll. The ~60s relay-credit poll calls render(), which rebuilt the
  <audio> element and chunks-scroll. render() now preserves the live <audio> node
  across the innerHTML swap (replaceWith when the src matches) and restores
  chunks-scroll scrollTop; initPodcastPlayer() is idempotent so the preserved node
  doesn't get duplicate listeners.

- Removed the redundant centered "Processing..." box; the staged pizza-tracker
  breadcrumb already covers that window.

- Added -webkit-overflow-scrolling/overscroll-behavior to .chunks-scroll for the
  mobile can't-scroll-to-top report (best-effort, needs on-device verification).

Ships as 0.2.157. Reviewer pass clean; inline JS syntax checked with node --check.
This commit is contained in:
Keysat
2026-06-15 17:38:32 -05:00
parent 91af0b711e
commit 693bb981ff
4 changed files with 108 additions and 12 deletions
+3 -1
View File
@@ -125,7 +125,7 @@ unsure whether a change is contract-affecting, assume it is and check.
## Current state ## Current state
**Live on the operator's StartOS box** (app **0.2.156** installed 2026-06-15 + relay **0.2.124**). Note: `recaps.cc` is served from this same box via Start9 Pages + StartTunnel, so a `make install` here updates the public cloud site automatically — there is no separate cloud deploy. **Live on the operator's StartOS box** (app **0.2.157** installed 2026-06-15 + relay **0.2.124**). Note: `recaps.cc` is served from this same box via Start9 Pages + StartTunnel, so a `make install` here updates the public cloud site automatically — there is no separate cloud deploy.
- **Self-serve purchase COMPLETE — all 5 phases** (`docs/self-serve-purchase-plan.md`). Signed-in cloud users buy Pro/Max themselves: "Pay with Bitcoin" renders an inline Lightning QR on-screen (no redirect); "Pay by card" mints a Zaprite one-time order (the card link shows only when the operator has configured Zaprite). Prepaid 30-day periods; the relay owns tier + expiry; both settle webhooks land at `extendUserTier`. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger: `POST /api/admin/reminders/run` with `{test_email}`. Tier cards show the real per-period credit allotment from the relay quota config (this box: Max = 120, Pro = 50). - **Self-serve purchase COMPLETE — all 5 phases** (`docs/self-serve-purchase-plan.md`). Signed-in cloud users buy Pro/Max themselves: "Pay with Bitcoin" renders an inline Lightning QR on-screen (no redirect); "Pay by card" mints a Zaprite one-time order (the card link shows only when the operator has configured Zaprite). Prepaid 30-day periods; the relay owns tier + expiry; both settle webhooks land at `extendUserTier`. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger: `POST /api/admin/reminders/run` with `{test_email}`. Tier cards show the real per-period credit allotment from the relay quota config (this box: Max = 120, Pro = 50).
- **Core-decoupling live** (relay owns cloud tier; `docs/core-decoupling-plan.md`) and **per-tenant subscriptions live** (`docs/per-tenant-subscriptions-plan.md`). - **Core-decoupling live** (relay owns cloud tier; `docs/core-decoupling-plan.md`) and **per-tenant subscriptions live** (`docs/per-tenant-subscriptions-plan.md`).
@@ -135,6 +135,8 @@ unsure whether a change is contract-affecting, assume it is and check.
**Also this session — iOS sign-in flake fixed (shipped as 0.2.156, built + installed + verified on the box):** an iPad user hit a spurious "network error" on the first tap of *Send sign-in link*, with the second tap succeeding. Root cause is the classic iOS Safari behavior of dispatching a `POST` onto a pooled keep-alive socket the server/proxy has already closed; unlike a GET it isn't transparently re-sent, so it surfaces as a transport `TypeError`. The existing single 500 ms auto-retry was too quick — it reused the same dead socket. Both sign-in entry points (`public/auth.html` `postWithRetry`, `public/index.html` `fetchWithRetry`) now retry 3× with growing backoff (0 → +400 ms → +1.6 s) to outlast Safari evicting the socket. Frontend-only, no server change; the embedded JS has no test harness. Mitigation not cure — if it ever recurs, confirm via box logs whether `/auth/request-link` is hit once (request never arrived → my diagnosis) or twice (failure on the response path → different bug) before widening the backoff. **Also this session — iOS sign-in flake fixed (shipped as 0.2.156, built + installed + verified on the box):** an iPad user hit a spurious "network error" on the first tap of *Send sign-in link*, with the second tap succeeding. Root cause is the classic iOS Safari behavior of dispatching a `POST` onto a pooled keep-alive socket the server/proxy has already closed; unlike a GET it isn't transparently re-sent, so it surfaces as a transport `TypeError`. The existing single 500 ms auto-retry was too quick — it reused the same dead socket. Both sign-in entry points (`public/auth.html` `postWithRetry`, `public/index.html` `fetchWithRetry`) now retry 3× with growing backoff (0 → +400 ms → +1.6 s) to outlast Safari evicting the socket. Frontend-only, no server change; the embedded JS has no test harness. Mitigation not cure — if it ever recurs, confirm via box logs whether `/auth/request-link` is hit once (request never arrived → my diagnosis) or twice (failure on the response path → different bug) before widening the backoff.
**Also this session — mobile/UX bug cluster from the inbox (shipped as 0.2.157, built + installed + verified; reviewer pass clean, no blockers):** four `public/index.html` fixes. (1) **Video minimize → black/needs-refresh:** `toggleVideoMinimize()` called `render()`, which rebuilt the YouTube `#yt-player` iframe inside the `display:none` minimized container and wedged the IFrame API. Now minimize toggles the `.results-left.minimized` CSS class in place (iframe stays mounted); a `!state.videoMinimized` guard on render's `needsMount` + a new `ensureYtMounted()` (called from the expand paths) ensure the player is never created in a hidden container. (2) **Background processing reset transcript scroll + killed podcast audio:** root cause was the ~60s relay-credit poll calling `render()`, which rebuilt the `<audio id="podcast-audio">` and `.chunks-scroll`. `render()` now preserves the live `<audio>` node across the innerHTML swap (`replaceWith` when the src matches — exploits the spec's async "pause on disconnect") and restores `.chunks-scroll` scrollTop; `initPodcastPlayer()` is idempotent (`dataset.inited`) so the preserved node doesn't double its listeners. (3) **Redundant centered "Processing…" box** removed (pizza-tracker breadcrumb already covers that window). (4) **Mobile can't-scroll-to-top:** added `-webkit-overflow-scrolling:touch` + `overscroll-behavior:contain` to `.chunks-scroll` — **best-effort, UNVERIFIED**; it's iOS-Safari-layout-specific and couldn't be reproduced off-device, so it needs an on-iPad check (and a screen recording if it persists). Inline JS syntax verified via `node --check` on the extracted script.
**Pending operator actions:** **Pending operator actions:**
1. (optional) Rotate the Gemini key in AI Studio — the purge removed it from the repo, but the key itself is still live. Then delete the pre-purge backup: `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle` (it contains the old key). 1. (optional) Rotate the Gemini key in AI Studio — the purge removed it from the repo, but the key itself is still live. Then delete the pre-purge backup: `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle` (it contains the old key).
2. Real-world cloud tests: first on-device Bitcoin purchase (Core tenant → Upgrade → Pay with Bitcoin → badge flips); enable cards (relay "Set Zaprite Connection" + webhook `https://<relay-host>/relay/zaprite/webhook`); eyeball a reminder email (`POST /api/admin/reminders/run` `{test_email}`). 2. Real-world cloud tests: first on-device Bitcoin purchase (Core tenant → Upgrade → Pay with Bitcoin → badge flips); enable cards (relay "Set Zaprite Connection" + webhook `https://<relay-host>/relay/zaprite/webhook`); eyeball a reminder email (`POST /api/admin/reminders/run` `{test_email}`).
+90 -9
View File
@@ -66,6 +66,10 @@
.results-right .stats-bar { flex-shrink: 0; } .results-right .stats-bar { flex-shrink: 0; }
.chunks-scroll { .chunks-scroll {
flex: 1; overflow-y: auto; padding-right: 4px; flex: 1; overflow-y: auto; padding-right: 4px;
/* iOS Safari: keep momentum scrolling and stop scroll-chaining to
the locked page body, which could leave the transcript unable to
pull back to the top on mobile. */
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
} }
.chunks-scroll::-webkit-scrollbar { width: 6px; } .chunks-scroll::-webkit-scrollbar { width: 6px; }
.chunks-scroll::-webkit-scrollbar-track { background: transparent; } .chunks-scroll::-webkit-scrollbar-track { background: transparent; }
@@ -1715,10 +1719,25 @@
// Auto-expand video if minimized when user clicks a transcript segment // Auto-expand video if minimized when user clicks a transcript segment
if (state.videoMinimized) { if (state.videoMinimized) {
state.videoMinimized = false; state.videoMinimized = false;
render(); const left = document.querySelector(".results-left");
// Wait for player to reinit, then seek if (state.currentType !== "podcast" && left) {
setTimeout(() => seekTo(seconds), 200); // YouTube: un-minimize via CSS. The player is usually still
return; // mounted, so fall through and seek immediately. If a background
// render() dropped it while hidden, mount it now and seek once
// it's ready.
left.classList.remove("minimized");
const ytDiv = document.getElementById("yt-player");
if (ytDiv && !ytDiv.querySelector("iframe")) {
ensureYtMounted();
setTimeout(() => seekTo(seconds), 300);
return;
}
} else {
// Podcast: render() rebuilds the <audio> element; seek after.
render();
setTimeout(() => seekTo(seconds), 200);
return;
}
} }
const sameSegment = (lastSeekTarget === seconds); const sameSegment = (lastSeekTarget === seconds);
@@ -1761,9 +1780,33 @@
} }
} }
// Mount the YT player into #yt-player when the div is present, visible,
// and missing its iframe. Centralizes the "never create the player in a
// hidden container" rule shared by render() and the expand paths.
function ensureYtMounted() {
if (!state.videoId || !ytReady || state.videoMinimized) return;
const ytDiv = document.getElementById("yt-player");
if (ytDiv && !ytDiv.querySelector("iframe")) initPlayer(state.videoId);
}
function toggleVideoMinimize() { function toggleVideoMinimize() {
state.videoMinimized = !state.videoMinimized; state.videoMinimized = !state.videoMinimized;
render(); // YouTube split view: minimize is purely visual — toggle the CSS
// class in place so the iframe (and any in-progress playback)
// survives. A full render() rebuilt the embed and re-created the
// YT player inside a display:none container, which wedged the
// IFrame API → a black frame that only a page reload could clear.
// The podcast view has no such class (it adds/removes the <audio>
// element and resizes the list), so it still goes through render().
const left = document.querySelector(".results-left");
if (state.currentType !== "podcast" && left) {
left.classList.toggle("minimized", state.videoMinimized);
// If a background render() dropped the iframe while we were hidden,
// mount it now that we're visible so expand never shows black.
if (!state.videoMinimized) ensureYtMounted();
} else {
render();
}
} }
function togglePlayPause() { function togglePlayPause() {
@@ -1886,7 +1929,11 @@
function initPodcastPlayer() { function initPodcastPlayer() {
const audio = document.getElementById("podcast-audio"); const audio = document.getElementById("podcast-audio");
if (!audio) return; // Idempotent: a node preserved live across a render() (see render's
// audio re-attach) keeps its listeners, so re-running here would
// double them up — startPodcastSync would fire twice per play.
if (!audio || audio.dataset.inited) return;
audio.dataset.inited = "1";
audio.addEventListener("play", startPodcastSync); audio.addEventListener("play", startPodcastSync);
audio.addEventListener("pause", stopPodcastSync); audio.addEventListener("pause", stopPodcastSync);
audio.addEventListener("ended", stopPodcastSync); audio.addEventListener("ended", stopPodcastSync);
@@ -6388,6 +6435,17 @@
// Preserve library sidebar scroll position across full re-renders // Preserve library sidebar scroll position across full re-renders
const __prevHistoryListEl = document.querySelector(".history-list"); const __prevHistoryListEl = document.querySelector(".history-list");
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0; const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
// Preserve the podcast <audio> as a LIVE node across the re-render
// so a background re-render (e.g. the ~60s relay-credit poll firing
// while a job debits credits) doesn't tear the player out of the
// DOM and stop playback. We hold the actual element here and
// re-attach it into the rebuilt tree below if the new markup wants
// the same src.
const __liveAudio = document.getElementById("podcast-audio");
// Preserve the transcript scroll position too — the rebuilt
// .chunks-scroll otherwise snaps back to the top on every render.
const __prevChunksEl = document.querySelector(".chunks-scroll");
const __prevChunksScroll = __prevChunksEl ? __prevChunksEl.scrollTop : 0;
const free = !isLicensed(); const free = !isLicensed();
// Submit is disabled when there's no URL, or when the selected // Submit is disabled when there's no URL, or when the selected
// providers don't have any usable configuration. Relay counts as // providers don't have any usable configuration. Relay counts as
@@ -6568,7 +6626,10 @@
${state.error ? `<div class="error-box">${escHtml(state.error)}</div>` : ""} ${state.error ? `<div class="error-box">${escHtml(state.error)}</div>` : ""}
${state.loading && (state.videoId || state.currentType === "podcast") && !state.streaming ? renderLoadingSplit() : ""} ${state.loading && (state.videoId || state.currentType === "podcast") && !state.streaming ? renderLoadingSplit() : ""}
${state.loading && !state.videoId && state.currentType !== "podcast" ? renderLoading() : ""} <!-- The old centered "Processing…" spinner box (renderLoading) was
removed here — the top pizza-tracker breadcrumb already shows
the Downloading→Transcribing→Analyzing stage during the
URL-resolution window, so the box was redundant. -->
${(state.chunks.length > 0 && !state.loading) || state.streaming ? renderResults() : ""} ${(state.chunks.length > 0 && !state.loading) || state.streaming ? renderResults() : ""}
@@ -6618,12 +6679,26 @@
// Cancel mid-stream, where the render path swapped from // Cancel mid-stream, where the render path swapped from
// renderLoadingSplit (mounted player) to renderResults (fresh // renderLoadingSplit (mounted player) to renderResults (fresh
// empty div) and the destroy/recreate raced. // empty div) and the destroy/recreate raced.
// Never mount into a minimized (display:none) container — creating
// the YT player there wedges the IFrame API and shows a black frame
// on expand. The expand handlers call ensureYtMounted() once visible.
const ytDiv = document.getElementById("yt-player"); const ytDiv = document.getElementById("yt-player");
const needsMount = state.videoId && ytReady && ytDiv && !ytDiv.querySelector("iframe"); const needsMount = state.videoId && ytReady && ytDiv && !ytDiv.querySelector("iframe") && !state.videoMinimized;
if (needsMount) { if (needsMount) {
setTimeout(() => initPlayer(state.videoId), 50); setTimeout(() => initPlayer(state.videoId), 50);
} }
// Init podcast audio player if present // Re-attach the preserved live <audio> node (kept playing across
// the DOM swap) when the rebuilt markup has a matching slot, so
// playback is seamless. Detaching + synchronously re-attaching in
// the same tick sidesteps the spec's "pause on disconnect" task.
// Only when the src matches — a real navigation to a different
// episode should get the fresh element instead.
const __audioSlot = document.getElementById("podcast-audio");
if (__liveAudio && __audioSlot && __liveAudio.src === __audioSlot.src) {
__audioSlot.replaceWith(__liveAudio);
}
// Init podcast audio player if present (idempotent — skips the
// preserved node, which already carries its listeners).
if (document.getElementById("podcast-audio")) { if (document.getElementById("podcast-audio")) {
setTimeout(initPodcastPlayer, 50); setTimeout(initPodcastPlayer, 50);
} }
@@ -6645,6 +6720,12 @@
const newList = document.querySelector(".history-list"); const newList = document.querySelector(".history-list");
if (newList) newList.scrollTop = __prevHistoryScroll; if (newList) newList.scrollTop = __prevHistoryScroll;
} }
// Restore transcript scroll so a background re-render doesn't bounce
// the reader back to the top of the sections list.
if (__prevChunksScroll > 0) {
const __newChunks = document.querySelector(".chunks-scroll");
if (__newChunks) __newChunks.scrollTop = __prevChunksScroll;
}
} }
function renderServerStatus() { function renderServerStatus() {
+3 -2
View File
@@ -175,8 +175,9 @@ import { v_0_2_153 } from './v0.2.153'
import { v_0_2_154 } from './v0.2.154' import { v_0_2_154 } from './v0.2.154'
import { v_0_2_155 } from './v0.2.155' import { v_0_2_155 } from './v0.2.155'
import { v_0_2_156 } from './v0.2.156' import { v_0_2_156 } from './v0.2.156'
import { v_0_2_157 } from './v0.2.157'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_2_156, current: v_0_2_157,
other: [v_0_2_155, v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0], other: [v_0_2_156, v_0_2_155, v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
}) })
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_157 = VersionInfo.of({
version: '0.2.157:0',
releaseNotes: {
en_US: 'Mobile/UX fixes: minimizing the video player no longer shows a black frame on expand (the iframe stays mounted instead of being rebuilt); background processing no longer interrupts podcast audio playback or jumps the transcript back to the top while a job runs; removed the redundant centered "Processing…" box (the staged progress tracker already covers it); transcript scrolling tuned for iOS.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})