A user reading a podcast script found the prompter ran past the end of the
event and on into the next one. An event is a unit of time with a stop of
its own, but auto-scroll bounded against the end of the whole document, so
nothing held it at the boundary. At a conference that means the prompter
reads the next speaker's script while they are still walking on.
Playback now runs to the end of the segment being read and parks there,
saying so. Loading the next event releases it through the existing follow,
and pressing play again carries on into the next segment for a reader who
wants to keep going. Only playback is held: a jump, a page or a nudge is
the reader asking to leave the segment and still crosses freely.
The segment is chosen once when playback starts, and held by identity
rather than as a pixel bound. Re-deciding it against a position which is
moving let the bound outrun the reader and never arrive; holding the id
rather than the offset keeps the stop on the same words when an edit moves
the script underneath it.
Add an option to show only the event being played, for the case where the
rest of the script is a distraction. It falls back to the whole script
while nothing is playing, where narrowing would leave a blank screen. The
script of the event being played is now held out from the rest, which stays
readable so a reader who is ahead or behind can still find their place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
The scroll position was a pixel offset into a document that changes while
it is being read. Growing an event above the reader left scrollTop where it
was, so the text under the reading line slid: measured going from "Lucas
Bennett" to "Lunch" on an unchanged offset of 900.
Hold the position as a place in the script instead — the block under the
reading line plus an offset into it — and re-resolve it to pixels whenever
the document is measured. This follows how professional prompters cue a
story plus an offset rather than a scroll offset, so a reorder carries the
reader with the block and an edit above them changes nothing. A deleted
block falls back to the end of the nearest surviving event before it.
Add event-to-event stepping on Shift with the vertical arrows, matching
Shift being the coarser step on the horizontal ones, and take the step from
where the scroll is headed so pressing again mid-ease moves on rather than
re-aiming at the same event.
Free scrolling and the existing nudge and page keys are unchanged, but they
now hand over the scroll by one rule: how far the reader moved the script
themselves, accumulated. Distance from the follow target could not tell a
catch-up still running from a reader who had moved, so touching the wheel
while the prompter eased towards a newly loaded event stopped it following.
Accumulating also measures a wheel gesture the browser spreads over many
frames, which the old per-event check sampled only once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
Two problems were feeding the confusion:
- Any wheel, touch, or pointerdown event broke the follow, with no tolerance.
A stray touch or trackpad momentum after a deliberate gesture disengaged it
as readily as a real scroll away from the read position, with no way to
tell the two apart from the outside. The operator view solved the same
problem with a distance check against the loaded item's expected position,
which this now mirrors: it takes a real scroll (past 1.5 lines) to count as
taking over, in a new hasBrokenFollow(), unit tested like the rest of the
scroll maths.
- The runtime flag and the view option shared adjacent, opposite-valence
names (followLocked / followLoaded) and were ANDed together in a third
place to get the value the button actually needed. Renamed the runtime
flag to autoScrollLocked, matching the operator's own lockAutoScroll for
the same concept, and moved the AND into the hook so it exposes one signal
the view no longer has to assemble itself.
Also fixes the threshold check reading a stale position: a burst of wheel
events can fire faster than the animation frame that keeps the scroll ref in
sync, so it now reads the element's scrollTop directly rather than the ref a
frame behind it. Caught by testing the tolerance in a live browser rather
than trusting the unit tests alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
* fix: issue where a count-to-end would lead to incorrect expected times
* fix: include add time in overtime when countToEnd
* fix: ui and server use same calculation for expected end
The server serves html/timer-legacy.html and html/login.html from disk
at runtime, relative to the bundled server. The Docker image copies
these files but the electron packaging did not, so the view 404ed in
all desktop distributions.
Additionally, AppImages mount at /tmp/.mount_*, a hidden directory.
Express sendFile refuses paths containing dot-segments by default
(returns 404 without touching disk), so the view failed on Linux even
with the file packaged. Allow dotfiles for this route; the request
path is fixed so no user input is affected.
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).
Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.
Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.
Fixes#2126
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY