Skip to content

fix: timer loading — zero duration, start failure, and keepScreenOn sync#10

Open
nbucic wants to merge 9 commits intomasterfrom
fix/timer-loading-and-keepscreenon
Open

fix: timer loading — zero duration, start failure, and keepScreenOn sync#10
nbucic wants to merge 9 commits intomasterfrom
fix/timer-loading-and-keepscreenon

Conversation

@nbucic
Copy link
Copy Markdown
Owner

@nbucic nbucic commented Apr 13, 2026

Root cause

When a program has no phases:

  • Program::totalDuration() returns 0 → programTotalDuration = 0
  • TimerRunner::start() throws RuntimeException("Program has no phases.") — this exception silently rolls back the Livewire response, so the UI never updates and the Start button appears to do nothing

This can happen when a user creates a program and navigates to the timer without adding any phases.

Changes

TimerScreen::start() — guard against empty phases

Instead of propagating an exception from TimerRunner::start(), the action now returns early when phases are empty. State is left untouched and the blade renders the "no phases" notice.

timer-screen.blade.php — no-phases notice

When the timer is idle and $phases is empty, the Start button is replaced with an amber warning: "No phases — edit this program to add at least one phase before starting." The Edit button in the secondary row is still shown so the user can fix the program immediately.

ProgramEditor::totalDuration() — match timer behaviour

The editor's preview summed every phase's cooldown, including the last phase. But the timer skips the last phase's cooldown (goes straight to COMPLETED after the final rep). This made the preview total higher than the actual run duration for any program with a last-phase cooldown. Fixed to subtract the last phase's cooldown, matching Program::totalDuration().

TimerScreen::loadProgram() / requestSettings() — forward keepScreenOn

Both methods dispatch settingsLoaded to sync JS audio state, but neither included keepScreenOn. The fix/tts-wakelock-caching commit added a settingsLoaded JS handler that reads keepScreenOn to gate wake-lock acquisition. Without it, the wake lock could be stuck in the wrong state whenever a program was loaded or settings were refreshed. Both dispatches now include keepScreenOn: $this->keepScreenOn.

Test plan

  • Open a program that has no phases → timer screen shows amber "No phases" notice, no Start button
  • Edit the same program, add a phase, save → redirected to timer, Start button visible, timer starts normally
  • Start a program with phases → 5-second PREPARE countdown plays, timer counts down correctly
  • Open program editor on a program whose last phase has a non-zero cooldown → preview total matches the actual timer run (cooldown excluded)
  • Open settings, toggle Keep Screen On, save → confirm the timer respects the new value on next program load

https://claude.ai/code/session_018ofANSCtBHs5P8rvvJ9Mh3

nbucic and others added 2 commits April 13, 2026 18:02
**TTS not playing (audio.js + TTSBridge.kt)**
- audio.js was checking window.NativePhp?.tts?.speak which never existed
  in the Android codebase — no Kotlin bridge was ever installed for it.
- Browser speechSynthesis fallback also silently failed because
  getVoices() returns [] synchronously; code never waited for the
  voiceschanged event, so no voice was selected.
- Fix JS side: check window.AndroidTTS?.speak (native Kotlin bridge,
  see PR description for Kotlin patch) and fix the browser fallback to
  wait for voiceschanged before calling speak().

**Screen wake lock never acquired (app.js + TimerScreen.php + Settings.php)**
- keepScreenOn setting was saved to DB (default true) but never consumed:
  TimerScreen.php did not expose it as a Livewire property, Settings.php
  did not include it in the settingsLoaded event, and app.js had no
  wake lock code whatsoever.
- Fix: expose keepScreenOn on TimerScreen, pass it through settingsLoaded,
  and use navigator.wakeLock.request('screen') in timerAudio Alpine
  component tied to timer state transitions (acquire on active states,
  release on idle/complete). Re-acquires automatically if OS releases it.

**TTS caching (TTSBridge.kt)**
- Once the native Kotlin bridge is applied, TTSBridge pre-synthesizes all
  known phrases (Done, Go, Next, Get ready, 3/2/1 …) to WAV files in the
  app cache directory on first launch and reloads them via MediaPlayer.
- Cache TTL is 3 days; stale/missing files are re-synthesized automatically.
- Known phrases play instantly with zero TTS engine startup latency.
  Dynamic labels (e.g. custom countdown text) fall back to live TTS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
**programTotalDuration = 0 / Start does nothing**
When a program has no phases, Program::totalDuration() returns 0 and
TimerRunner::start() throws "Program has no phases." — an exception that
silently rolls back the Livewire response, leaving the UI unchanged.
- TimerScreen::start() now returns early when phases are empty instead
  of throwing, so the component stays consistent.
- timer-screen.blade.php shows an amber "No phases" notice in place of
  the Start button when idle and phases is empty, directing the user to
  edit the program.

**ProgramEditor::totalDuration() showed wrong total**
The editor's preview summed the last phase's cooldown, but the timer
skips that cooldown (goes straight to Complete after the last rep).
Fixed totalDuration() to subtract the last phase's cooldown, matching
Program::totalDuration() so the displayed preview equals the actual run.

**keepScreenOn not forwarded in settingsLoaded events**
The TTS/wake-lock commit exposed keepScreenOn in TimerScreen and added
a settingsLoaded JS handler, but loadProgram() and requestSettings()
still dispatched the event without keepScreenOn. The wake lock could
get stuck in the wrong state if keepScreenOn was false. Both dispatches
now include keepScreenOn: $this->keepScreenOn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Base automatically changed from fix/tts-wakelock-caching to master April 16, 2026 08:04
nbucic and others added 7 commits April 16, 2026 11:09
On Android WebView, speechSynthesis.voiceschanged may never fire, which
caused the speak() fallback path to register a listener that never ran --
effectively silencing all TTS when window.AndroidTTS is not yet available
(i.e. before the APK is rebuilt with TTSBridge.kt).

Fix: speak immediately using whatever voices are already loaded; the
default voice is used if no en-* voice is found, which is correct
behaviour on Android where voice selection is handled by the TTS engine.

Also replace smart-quote/em-dash characters in comments with plain ASCII
to prevent future string-match failures in automated tooling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
**Demo HIIT program (seeder)**
- composer.json: add Database\\Seeders\\ to production autoload.psr-4
  so the seeder is available in the embedded NativePHP runtime.
- database/seeders/DatabaseSeeder.php: new seeder that creates the
  demo HIIT program (Warmup 10s / Sprint 8sx3 / Stretch 8s) when the
  programs table is empty. Idempotent via Program::exists() guard.
  Calls Setting::current() first so the settings row exists before
  Program construction touches it.
- AppServiceProvider::boot(): call DatabaseSeeder wrapped in try/catch
  so the inevitable "table does not exist" error during `artisan migrate`
  itself is swallowed gracefully and succeeds on the next boot.

**TTS diagnostic logging**
JS (app.js + audio.js):
- timerAudio init(): logs soundMode, keepScreenOn, and whether
  window.AndroidTTS is already present at page-load time.
- playBeep listener: logs voiceText() output before calling speak()
  so an empty countdownLabel is visible before the call.
- speak(): logs which path is taken (AndroidTTS bridge / speechSynthesis
  / neither), voice count + selection, and attaches utt.onerror so
  SpeechSynthesisUtterance failures surface in the console instead of
  silently disappearing.

Kotlin (TTSBridge.kt + WebViewManager.kt -- gitignored):
- WebViewManager: log after AndroidTTS bridge registration.
- TTSBridge.speak(): entry log with text always fires (was missing).
- TTSBridge.speak(): explicit cache-miss log before speakLive().
- TTSBridge.speakLive(): tts null-check with error log.
- TTSBridge.prebuildCache(): log phrase count at start.
- All Kotlin log messages prefixed [TTS] for easy Logcat filtering:
  adb logcat -s PHPMonitor:D PHPMonitor-Console:D TTSBridge:D

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a path-repository package that wraps Android TTS (and a Web Speech
API fallback) into a NativePHP-style bridge (Kotlin + PHP + JS).
Registers it via a new NativeServiceProvider and integrates it into
Settings with an updateAndTest() action for in-app voice testing.
Also bumps nativephp/mobile to 3.2.3 and pins axios to 1.15.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…bundle

- Import audioTTS package module directly instead of relying on window.AndroidTTS bridge
- Add 'database' to NativePHP bundled paths so SQLite DB is included on device
- Fix countdown label off-by-one (show remaining - 1 during countdown)
- Allow cooldown input on last phase (was incorrectly disabled); improve helper text
- Add "Demo with no phases" seed program for edge-case testing
- Clean up debug logging, debugger statements, and unused imports in Settings/audio
- Gitignore AI tooling files (.claude, CLAUDE.md, .agents, etc.)
@nbucic
Copy link
Copy Markdown
Owner Author

nbucic commented Apr 16, 2026

@copilot resolve the merge conflicts in this pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants