# @name Standup Timer # @desc For practicing fixed-time standup comedy sets. # @author Matt Stein # @version 1.0 # @desc SELECT: tap to start/pause, double-tap to reset. Flashes for the # @desc last 5s before the countdown reaches the configured warning mark. # @config minutes slider "Minutes" default=5 min=1 max=15 unit=min # @config flash_min slider "Flash at (min left)" default=1 min=1 max=14 unit=min # @config icon_id text "Icon ID" default="1010" # 1-minute warning: five seconds of flashing that finishes right as the # countdown crosses the threshold, so it reads as a lead-up rather than a # reaction. FLASH_ON_MS = 500 FLASH_OFF_MS = 500 FLASH_COUNT = 5 FLASH_TOTAL_MS = FLASH_COUNT * (FLASH_ON_MS + FLASH_OFF_MS) # Flip on to sanity-check the flash and 0:00 transitions without sitting # through a full countdown: a run starts 15s before the flash window and the # flash always ends 5s before 0:00, ignoring the minutes/flash_min config. TEST_MODE = false TEST_PRE_FLASH_MS = 15000 TEST_THRESHOLD_MS = 5000 # 0:00 closing sequence: a short black beat, then the message blinks until # the button starts a new run or resets. DONE_DIP_MS = 600 DONE_MSG_CHARS = ["F", "U", "C", "K", " ", "O", "F", "F", "!"] class StandupTimer var state # "stopped" | "running" | "paused" | "done" var start_ms var remaining_ms # frozen remaining time while paused/stopped/done var warned # flash warning already fired this run var flashing var flash_start_ms var last_press_ms var pending_toggle var done_start_ms # when the countdown hit zero, for the 0:00 hold + blink sequence def init() self.state = "stopped" self.start_ms = 0 self.remaining_ms = self.duration_ms() self.warned = false self.flashing = false self.last_press_ms = 0 self.pending_toggle = false self.done_start_ms = 0 end # Convenience variables — both configurable from the app's settings in the # AWTRIX UI, no code edits needed. def duration_ms() if TEST_MODE return TEST_PRE_FLASH_MS + FLASH_TOTAL_MS + TEST_THRESHOLD_MS end return store.get("minutes", 5) * 60000 end def flash_threshold_ms() if TEST_MODE return TEST_THRESHOLD_MS end return store.get("flash_min", 1) * 60000 end def on_button(btn) if btn != "select" return end var t = now_ms() if self.pending_toggle && (t - self.last_press_ms) < 400 self.pending_toggle = false self.reset() else self.pending_toggle = true self.last_press_ms = t end end def reset() self.state = "stopped" self.remaining_ms = self.duration_ms() self.warned = false self.flashing = false end # A single tap after the countdown finishes jumps straight into a fresh run — # handy for back-to-back practice reps. Double-tap still resets to idle. def toggle() if self.state == "stopped" || self.state == "done" self.state = "running" self.remaining_ms = self.duration_ms() self.warned = false self.start_ms = now_ms() elif self.state == "running" self.remaining_ms = self.current_remaining() self.state = "paused" elif self.state == "paused" self.state = "running" self.start_ms = now_ms() - (self.duration_ms() - self.remaining_ms) end end def current_remaining() var r = self.duration_ms() - (now_ms() - self.start_ms) if r < 0 r = 0 end return r end # Draws chars edge-to-edge: measures each glyph, then spreads the leftover # width evenly across the gaps so the message spans the full panel width # instead of sitting centered with default spacing. The gap at the space # between words gets one extra pixel on top of that. def draw_fill_width(chars, y, color) var n = size(chars) if n == 0 return end var widths = [] var total_w = 0 var i = 0 while i < n var cw = text_ink_width(chars[i]) widths.push(cw) total_w += cw i += 1 end var gaps = n - 1 var extra = 0 if gaps > 0 extra = int((width() - total_w) / gaps) if extra < 0 extra = 0 end end var x = 0 i = 0 while i < n text(x, y, chars[i], color) x += widths[i] + extra if chars[i] == " " x += 1 end i += 1 end end def draw() if self.pending_toggle && (now_ms() - self.last_press_ms) >= 400 self.pending_toggle = false self.toggle() end if self.state == "running" self.remaining_ms = self.current_remaining() # Cross into "done" (and red) the instant the displayed time would # read 0:00, not only once the underlying ms reaches exactly zero — # otherwise the last fractional second still shows green. if self.remaining_ms < 1000 self.state = "done" self.remaining_ms = 0 self.done_start_ms = now_ms() elif !self.warned && self.remaining_ms <= self.flash_threshold_ms() + FLASH_TOTAL_MS self.warned = true self.flashing = true self.flash_start_ms = now_ms() end end # Once the countdown's been sitting at 0:00 for 5s, drop the readout # entirely: dip to black briefly, then blink the closing message until # the button starts a new run or resets. if self.state == "done" && (now_ms() - self.done_start_ms) >= 5000 clear() var t2 = now_ms() - self.done_start_ms - 5000 if t2 < DONE_DIP_MS return end var phase = int((t2 - DONE_DIP_MS) / 750) % 2 if phase == 0 self.draw_fill_width(DONE_MSG_CHARS, 6, 0xFF0000) end return end clear() var mic = store.get("icon_id", "") if mic != "" icon(mic, 0, 0) end var total_s = int(self.remaining_ms / 1000) var m = int(total_s / 60) var s = total_s % 60 var s_str = str(s) if s < 10 s_str = "0" + s_str end var label = str(m) + ":" + s_str var color = 0xFFFFFF if self.state == "running" # Once the flash warning has fired, the numbers stay yellow for the # rest of the run — a held cue between the white flash and the red # 0:00 state, not just green until it suddenly goes red. if self.warned color = 0xFFFF00 else color = 0x00FF00 end elif self.state == "paused" color = 0x888888 elif self.state == "done" color = 0xFF0000 end # Right-justified: the readout's width varies (0:00 vs 14:59), so anchor # its right edge instead of its left. var label_w = text_ink_width(label) var label_x = width() - 1 - label_w if self.flashing var t = now_ms() - self.flash_start_ms if t >= FLASH_TOTAL_MS self.flashing = false else var cycle = t % (FLASH_ON_MS + FLASH_OFF_MS) if cycle < FLASH_ON_MS # Flash the whole panel — fine to blot out the icon — but the # digits still draw on top below, so they stay legible. rect_fill(0, 0, width(), height(), 0xFFFFFF) end end end text(label_x, 6, label, color) end end return StandupTimer()