From 05e6eaea70eb52b0df530d3b6f9f05a9dbea12e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Buci=C4=87?= Date: Mon, 13 Apr 2026 18:02:51 +0200 Subject: [PATCH 1/9] fix: TTS playback, screen wake lock, and TTS caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- app/Livewire/Settings.php | 2 +- app/Livewire/TimerScreen.php | 6 ++-- resources/js/app.js | 69 +++++++++++++++++++++++++++++------- resources/js/audio.js | 47 +++++++++++++----------- 4 files changed, 87 insertions(+), 37 deletions(-) diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 305b63c..65593c0 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -60,7 +60,7 @@ public function save(): void $settings->save(); - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: null); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: null); $this->saved = true; } diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php index abb7958..880361f 100644 --- a/app/Livewire/TimerScreen.php +++ b/app/Livewire/TimerScreen.php @@ -43,6 +43,7 @@ class TimerScreen extends Component public string $soundMode = 'beep'; public float $volume = 0.8; public string $endSound = 'triple'; + public bool $keepScreenOn = true; // ── Ring countdown ──────────────────────────────────────────────────── public int $programTotalDuration = 0; @@ -57,8 +58,9 @@ class TimerScreen extends Component public function mount(?string $id = null): void { $settings = Setting::current(); - $this->soundMode = $settings->sound_mode; - $this->volume = $settings->volume; + $this->soundMode = $settings->sound_mode; + $this->volume = $settings->volume; + $this->keepScreenOn = $settings->keep_screen_on; if ($id) { $this->loadProgram($id); diff --git a/resources/js/app.js b/resources/js/app.js index 8724cee..6d73d6e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -8,22 +8,27 @@ document.addEventListener('alpine:init', () => { audio: null, soundMode: 'beep', volume: 0.8, + keepScreenOn: true, interval: null, + _wakeLock: null, + init() { - this.soundMode = this.$wire.soundMode; - this.volume = this.$wire.volume; - this.program = this.$wire.program; - this.audio = initAudio(this.volume); + this.soundMode = this.$wire.soundMode; + this.volume = this.$wire.volume; + this.keepScreenOn = this.$wire.keepScreenOn; + this.program = this.$wire.program; + this.audio = initAudio(this.volume); // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active this.$watch('$wire.state', state => { - // Handle Livewire Enum serialization (v3) - const stateName = state; - console.log('State changed:', stateName); + console.log('State changed:', state); clearInterval(this.interval); - if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) { + if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(state)) { this.interval = setInterval(() => this.$wire.tick(), 1000); + this._acquireWakeLock(); + } else { + this._releaseWakeLock(); } }); @@ -49,18 +54,56 @@ document.addEventListener('alpine:init', () => { this.$wire.on('playPauseBeep', () => { this.audio.pauseBeep(); }); + + // Sync settings changes saved from the Settings screen + this.$wire.on('settingsLoaded', ({soundMode, volume, keepScreenOn}) => { + if (soundMode !== undefined) this.soundMode = soundMode; + if (volume !== undefined) { + this.volume = volume; + this.audio = initAudio(volume); + } + if (keepScreenOn !== undefined) this.keepScreenOn = keepScreenOn; + }); }, + voiceText(reason) { const map = { - prepare: this.$wire?.countdownLabel ?? '', - countdown: this.$wire?.countdownLabel ?? 'Get ready', - rep_end: 'Done', - pause_end: 'Go', + prepare: this.$wire?.countdownLabel ?? '', + countdown: this.$wire?.countdownLabel ?? 'Get ready', + rep_end: 'Done', + pause_end: 'Go', cooldown_end: 'Next', }; return map[reason] ?? 'Beep'; }, + + async _acquireWakeLock() { + if (!this.keepScreenOn) return; + if (!('wakeLock' in navigator)) return; + if (this._wakeLock) return; // already held + try { + this._wakeLock = await navigator.wakeLock.request('screen'); + console.log('Wake lock acquired'); + // Re-acquire automatically if the OS releases it (e.g. tab hidden then shown) + this._wakeLock.addEventListener('release', () => { + this._wakeLock = null; + }); + } catch (e) { + console.warn('Wake lock request failed:', e.message); + } + }, + + async _releaseWakeLock() { + if (!this._wakeLock) return; + try { + await this._wakeLock.release(); + console.log('Wake lock released'); + } catch (e) { + console.warn('Wake lock release failed:', e.message); + } + this._wakeLock = null; + }, })); }); -// Alpine is automatically started by Livewire v3. +// Alpine is automatically started by Livewire v3. \ No newline at end of file diff --git a/resources/js/audio.js b/resources/js/audio.js index 5e7a7cc..0bf7877 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -4,7 +4,7 @@ * Audio engine for the interval timer. * * soundMode = 'beep' → Web Audio API (synthesized, no network) - * soundMode = 'voice' → Android TTS via NativePHP JS bridge (feminine, calm) + * soundMode = 'voice' → Android TTS via window.AndroidTTS bridge (TTSBridge.kt) * * All methods are no-ops until the user has interacted with the page, * satisfying the browser AudioContext autoplay policy. @@ -76,31 +76,36 @@ export function initAudio(volume = 0.8) { } /** - * Android TTS via NativePHP bridge. - * Falls back to Web Speech API on a web browser. + * Speak text via Android TTS bridge (window.AndroidTTS) when running + * inside the NativePHP WebView, with a Web Speech API fallback for + * browser-based development. */ function speak(text) { - if (window.NativePhp?.tts?.speak) { - // NativePHP Mobile TTS plugin (feminine, calm pitch) - window.NativePhp.tts.speak({ - text, - voice: 'female', - pitch: 0.9, - rate: 0.85, - }); + // Android bridge registered by TTSBridge.kt + if (window.AndroidTTS?.speak) { + window.AndroidTTS.speak(text); return; } - // Browser fallback + // Browser fallback — voices load asynchronously, so wait for them if ('speechSynthesis' in window) { - const utt = new SpeechSynthesisUtterance(text); - utt.pitch = 0.9; - utt.rate = 0.85; - utt.volume = volume; - const voices = speechSynthesis.getVoices(); - const fem = voices.find(v => v.name.toLowerCase().includes('female')) - || voices.find(v => v.lang.startsWith('en')); - if (fem) utt.voice = fem; - speechSynthesis.speak(utt); + const utt = new SpeechSynthesisUtterance(text); + utt.pitch = 0.9; + utt.rate = 0.85; + utt.volume = volume; + + const doSpeak = () => { + const voices = speechSynthesis.getVoices(); + const voice = voices.find(v => v.name.toLowerCase().includes('female')) + || voices.find(v => v.lang.startsWith('en')); + if (voice) utt.voice = voice; + speechSynthesis.speak(utt); + }; + + if (speechSynthesis.getVoices().length > 0) { + doSpeak(); + } else { + speechSynthesis.addEventListener('voiceschanged', doSpeak, { once: true }); + } } } From 32c73625ac5cd377b72602c922dac9cf11d4da51 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 23:33:29 +0000 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20timer=20loading=20=E2=80=94=20zero?= =?UTF-8?q?=20duration,=20start=20failure,=20and=20keepScreenOn=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- app/Livewire/ProgramEditor.php | 9 ++++++++- app/Livewire/TimerScreen.php | 8 ++++++-- resources/views/livewire/timer-screen.blade.php | 7 +++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/Livewire/ProgramEditor.php b/app/Livewire/ProgramEditor.php index 76559d8..1372630 100644 --- a/app/Livewire/ProgramEditor.php +++ b/app/Livewire/ProgramEditor.php @@ -217,7 +217,11 @@ public function formattedDuration(): string public function totalDuration(): int { - return array_reduce( + if (empty($this->phases)) { + return 0; + } + + $total = array_reduce( $this->phases, static function (int $carry, array $p): int { $repTime = $p['duration'] * $p['repetitions']; @@ -226,6 +230,9 @@ static function (int $carry, array $p): int { }, 0, ); + + // The last phase's cooldown is never executed (timer goes straight to Complete). + return $total - (int) ($this->phases[array_key_last($this->phases)]['cooldown'] ?? 0); } /** diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php index 880361f..e0b71ec 100644 --- a/app/Livewire/TimerScreen.php +++ b/app/Livewire/TimerScreen.php @@ -85,7 +85,7 @@ public function loadProgram(string $id): void $this->syncCursor($runner->cursor(), $program); $this->dispatch('topbar-title', title: $program->name); - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); } public function discard(): void @@ -128,6 +128,10 @@ public function start(): void $runner = app(TimerRunner::class); $program = Program::with('phases')->findOrFail($this->programId); + if ($program->phases->isEmpty()) { + return; + } + $this->programTotalDuration = $program->totalDuration(); $runner->load($program); $runner->start(); @@ -162,7 +166,7 @@ public function tick(): void public function requestSettings(): void { $program = $this->programId ? Program::with('phases')->find($this->programId) : null; - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); } public function render(): View diff --git a/resources/views/livewire/timer-screen.blade.php b/resources/views/livewire/timer-screen.blade.php index b405b1f..c02093d 100644 --- a/resources/views/livewire/timer-screen.blade.php +++ b/resources/views/livewire/timer-screen.blade.php @@ -259,6 +259,12 @@ class="text-gray-800 text-xs mt-2 select-none" {{-- Primary action --}} @if($state === StateMachine::idle) + @if(count($phases) === 0) +
+ No phases — edit this program to add at least one phase before starting. +
+ @else + @endif @elseif($state === StateMachine::prepare) {{-- No primary action during prepare — user just waits --}} From 047b046a48fa81cc3848b21adbfc9b1cdea7392b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Buci=C4=87?= Date: Wed, 15 Apr 2026 12:26:36 +0200 Subject: [PATCH 3/9] fix: remove voiceschanged wait that silently hung on Android WebView 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 --- resources/js/audio.js | 48 +++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/resources/js/audio.js b/resources/js/audio.js index 0bf7877..5bfc59f 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -3,8 +3,9 @@ /** * Audio engine for the interval timer. * - * soundMode = 'beep' → Web Audio API (synthesized, no network) - * soundMode = 'voice' → Android TTS via window.AndroidTTS bridge (TTSBridge.kt) + * soundMode = 'beep' -> Web Audio API (synthesized, no network) + * soundMode = 'voice' -> Android TTS via window.AndroidTTS bridge (TTSBridge.kt) + * Falls back to Web Speech API in browser dev. * * All methods are no-ops until the user has interacted with the page, * satisfying the browser AudioContext autoplay policy. @@ -45,7 +46,7 @@ export function initAudio(volume = 0.8) { /** Single countdown beep (800 Hz, 100 ms). */ function beep() { tone(800, 100); } - /** Prepare-phase beep — three rapid beeps at the same tone (800 Hz, 100 ms × 3). */ + /** Prepare-phase beep -- three rapid beeps at the same tone (800 Hz, 100 ms x 3). */ function prepareBeep() { tone(800, 100, 0); tone(800, 100, 150); @@ -76,36 +77,33 @@ export function initAudio(volume = 0.8) { } /** - * Speak text via Android TTS bridge (window.AndroidTTS) when running - * inside the NativePHP WebView, with a Web Speech API fallback for - * browser-based development. + * Speak text via the Android TTS bridge (window.AndroidTTS, registered by + * TTSBridge.kt) when running inside the NativePHP WebView. + * + * Falls back to Web Speech API for browser-based development. + * Speaks immediately without waiting for voiceschanged -- on Android WebView + * that event may never fire, so we use whatever voices are already loaded + * (the default voice is fine if none are found). */ function speak(text) { - // Android bridge registered by TTSBridge.kt - if (window.AndroidTTS?.speak) { + // Android native bridge (requires APK rebuilt with TTSBridge.kt) + if (window.AndroidTTS && typeof window.AndroidTTS.speak === 'function') { window.AndroidTTS.speak(text); return; } - // Browser fallback — voices load asynchronously, so wait for them + // Web Speech API fallback (browser dev / older APK) if ('speechSynthesis' in window) { - const utt = new SpeechSynthesisUtterance(text); + const utt = new SpeechSynthesisUtterance(text); utt.pitch = 0.9; utt.rate = 0.85; utt.volume = volume; - - const doSpeak = () => { - const voices = speechSynthesis.getVoices(); - const voice = voices.find(v => v.name.toLowerCase().includes('female')) - || voices.find(v => v.lang.startsWith('en')); - if (voice) utt.voice = voice; - speechSynthesis.speak(utt); - }; - - if (speechSynthesis.getVoices().length > 0) { - doSpeak(); - } else { - speechSynthesis.addEventListener('voiceschanged', doSpeak, { once: true }); - } + // Attempt voice selection but do NOT block on voiceschanged -- + // on Android WebView it may never fire. + const voices = speechSynthesis.getVoices(); + const voice = voices.find(v => v.lang.startsWith('en-')) + || voices.find(v => v.lang.startsWith('en')); + if (voice) utt.voice = voice; + speechSynthesis.speak(utt); } } @@ -117,4 +115,4 @@ export function initAudio(volume = 0.8) { chime, speak, }; -} +} \ No newline at end of file From 99f4db52a833ea33298fe7bf9dacfec67a060b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Buci=C4=87?= Date: Wed, 15 Apr 2026 12:44:04 +0200 Subject: [PATCH 4/9] feat: demo HIIT seeder on fresh install + TTS diagnostic logging **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 --- .gitignore | 3 ++ app/Providers/AppServiceProvider.php | 12 +++++++- composer.json | 6 +++- database/seeders/DatabaseSeeder.php | 43 ++++++++++++++++++++++++++++ resources/js/app.js | 10 +++++-- resources/js/audio.js | 33 ++++++++++++++------- 6 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 database/seeders/DatabaseSeeder.php diff --git a/.gitignore b/.gitignore index 944856f..3020261 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db /database/database.sqlite /app-release-signed.apk* /my-release-key* + +# Credential files (keystores, private keys, etc.) +/credentials/ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 608f2fb..8f6fb84 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Timer\TimerRunner; +use Database\Seeders\DatabaseSeeder; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -20,6 +21,15 @@ public function register(): void */ public function boot(): void { - // + // On first install (empty programs table) seed the demo HIIT program. + // The guard inside DatabaseSeeder::run() makes this idempotent. + // Wrapped in try/catch: during `artisan migrate` the programs table + // does not yet exist when the service provider boots — swallow that + // gracefully and let the seeder succeed on the next boot. + try { + (new DatabaseSeeder)->run(); + } catch (\Throwable $e) { + report($e); + } } } diff --git a/composer.json b/composer.json index e84e93a..94b5737 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ }, "autoload": { "psr-4": { - "App\\": "app/" + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/" } }, "autoload-dev": { @@ -54,6 +55,9 @@ "@php artisan config:clear --ansi", "@php artisan test" ], + "build": [ + "@php artisan native:package android" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..9ea6644 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,43 @@ + 'HIIT', + 'beep_lead_in' => BeepLeadIn::Three->value, + 'end_sound' => 'chime', + ]); + + foreach ([ + ['label' => 'Warmup', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 5, 'color' => '#3b82f6'], + ['label' => 'Sprint', 'duration' => 8, 'repetitions' => 3, 'pause' => 4, 'cooldown' => 10, 'color' => '#ef4444'], + ['label' => 'Stretch','duration' => 8, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#22c55e'], + ] as $phase) { + $program->addPhase($phase); + } + } +} diff --git a/resources/js/app.js b/resources/js/app.js index 6d73d6e..dae0582 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -19,6 +19,10 @@ document.addEventListener('alpine:init', () => { this.program = this.$wire.program; this.audio = initAudio(this.volume); + console.log('[TTS] timerAudio init: soundMode=' + this.soundMode + + ', keepScreenOn=' + this.keepScreenOn + + ', AndroidTTS=' + !!(window.AndroidTTS && typeof window.AndroidTTS.speak === 'function')); + // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active this.$watch('$wire.state', state => { console.log('State changed:', state); @@ -35,7 +39,9 @@ document.addEventListener('alpine:init', () => { this.$wire.on('playBeep', ({reason}) => { console.log('playBeep', reason); if (this.soundMode === 'voice') { - this.audio.speak(this.voiceText(reason)); + const text = this.voiceText(reason); + console.log('[TTS] playBeep: reason=' + reason + ', voiceText="' + text + '"'); + this.audio.speak(text); } else if (reason === 'prepare') { this.audio.prepareBeep(); } else { @@ -106,4 +112,4 @@ document.addEventListener('alpine:init', () => { })); }); -// Alpine is automatically started by Livewire v3. \ No newline at end of file +// Alpine is automatically started by Livewire v3. diff --git a/resources/js/audio.js b/resources/js/audio.js index 5bfc59f..b3cd545 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -81,30 +81,43 @@ export function initAudio(volume = 0.8) { * TTSBridge.kt) when running inside the NativePHP WebView. * * Falls back to Web Speech API for browser-based development. - * Speaks immediately without waiting for voiceschanged -- on Android WebView - * that event may never fire, so we use whatever voices are already loaded - * (the default voice is fine if none are found). + * Logs every decision point so the full chain is visible in both + * Android Logcat (via WebChromeClient console forwarding) and DevTools. */ function speak(text) { // Android native bridge (requires APK rebuilt with TTSBridge.kt) if (window.AndroidTTS && typeof window.AndroidTTS.speak === 'function') { + console.log('[TTS] speak(): AndroidTTS bridge -> "' + text + '"'); window.AndroidTTS.speak(text); return; } - // Web Speech API fallback (browser dev / older APK) + + // Web Speech API fallback (browser dev / APK without TTSBridge compiled in) if ('speechSynthesis' in window) { + const voices = speechSynthesis.getVoices(); + const voice = voices.find(v => v.lang.startsWith('en-')) + || voices.find(v => v.lang.startsWith('en')); + console.log('[TTS] speak(): speechSynthesis path' + + ', text="' + text + '"' + + ', voices=' + voices.length + + ', voice=' + (voice ? voice.name + ' (' + voice.lang + ')' : 'default')); + const utt = new SpeechSynthesisUtterance(text); utt.pitch = 0.9; utt.rate = 0.85; utt.volume = volume; - // Attempt voice selection but do NOT block on voiceschanged -- - // on Android WebView it may never fire. - const voices = speechSynthesis.getVoices(); - const voice = voices.find(v => v.lang.startsWith('en-')) - || voices.find(v => v.lang.startsWith('en')); if (voice) utt.voice = voice; + utt.onerror = (e) => { + console.error('[TTS] SpeechSynthesisUtterance error: ' + e.error + + ' for "' + text + '"'); + }; speechSynthesis.speak(utt); + return; } + + console.warn('[TTS] speak(): no TTS available' + + ' (no AndroidTTS bridge, no speechSynthesis)' + + ', text="' + text + '"'); } return { @@ -115,4 +128,4 @@ export function initAudio(volume = 0.8) { chime, speak, }; -} \ No newline at end of file +} From 2c3c8222001aaa6c51052fc3f71a7b1700bdecd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Buci=C4=87?= Date: Thu, 16 Apr 2026 10:00:40 +0200 Subject: [PATCH 5/9] feat: introduce nbucic/audio-tts as a local NativePHP plugin 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 --- .gitignore | 2 +- app/Livewire/Settings.php | 39 +- app/Providers/AppServiceProvider.php | 7 +- app/Providers/NativeServiceProvider.php | 42 + composer.json | 10 +- composer.lock | 247 +++- package-lock.json | 230 ++-- package.json | 2 +- packages/nbucic/audio-tts/.gitignore | 4 + packages/nbucic/audio-tts/README.md | 37 + packages/nbucic/audio-tts/composer.json | 42 + packages/nbucic/audio-tts/nativephp.json | 73 ++ .../resources/android/AudioTTSFunctions.kt | 157 +++ .../resources/boost/guidelines/core.blade.php | 61 + .../resources/ios/AudioTTSFunctions.swift | 27 + .../nbucic/audio-tts/resources/js/audioTTS.js | 82 ++ packages/nbucic/audio-tts/src/AudioTTS.php | 40 + .../audio-tts/src/AudioTTSServiceProvider.php | 26 + .../src/Commands/CopyAssetsCommand.php | 65 + .../src/Events/AudioTTSCompleted.php | 16 + .../nbucic/audio-tts/src/Facades/AudioTTS.php | 19 + packages/nbucic/audio-tts/tests/Pest.php | 9 + .../nbucic/audio-tts/tests/PluginTest.php | 221 ++++ resources/js/app.js | 21 + resources/js/audio.js | 36 +- resources/views/livewire/settings.blade.php | 31 +- tmp/MainActivity.kt | 1044 +++++++++++++++++ tmp/TTSBridge.kt | 174 +++ tmp/WebViewManager.kt | 584 +++++++++ vite.config.js | 5 +- 30 files changed, 3189 insertions(+), 164 deletions(-) create mode 100644 app/Providers/NativeServiceProvider.php create mode 100644 packages/nbucic/audio-tts/.gitignore create mode 100644 packages/nbucic/audio-tts/README.md create mode 100644 packages/nbucic/audio-tts/composer.json create mode 100644 packages/nbucic/audio-tts/nativephp.json create mode 100644 packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt create mode 100644 packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php create mode 100644 packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift create mode 100644 packages/nbucic/audio-tts/resources/js/audioTTS.js create mode 100644 packages/nbucic/audio-tts/src/AudioTTS.php create mode 100644 packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php create mode 100644 packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php create mode 100644 packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php create mode 100644 packages/nbucic/audio-tts/src/Facades/AudioTTS.php create mode 100644 packages/nbucic/audio-tts/tests/Pest.php create mode 100644 packages/nbucic/audio-tts/tests/PluginTest.php create mode 100644 tmp/MainActivity.kt create mode 100644 tmp/TTSBridge.kt create mode 100644 tmp/WebViewManager.kt diff --git a/.gitignore b/.gitignore index 3020261..59baa88 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db -/nativephp + /database/database.sqlite /app-release-signed.apk* /my-release-key* diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 65593c0..d96a60f 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -6,11 +6,13 @@ use App\Enum\BeepLeadIn; use App\Models\Setting; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rules\Enum; use Illuminate\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; +use Nbucic\AudioTts\AudioTTS; #[Layout('layouts.app')] #[Title('Settings — Interval Timer')] @@ -29,10 +31,10 @@ public function mount(): void $settings = Setting::current(); $this->defaultBeepLeadIn = $settings->default_beep_lead_in; - $this->defaultEndSound = $settings->default_end_sound; - $this->soundMode = $settings->sound_mode; - $this->volume = $settings->volume; - $this->keepScreenOn = $settings->keep_screen_on; + $this->defaultEndSound = $settings->default_end_sound; + $this->soundMode = $settings->sound_mode; + $this->volume = $settings->volume; + $this->keepScreenOn = $settings->keep_screen_on; } public function render(): View @@ -44,19 +46,19 @@ public function save(): void { $this->validate([ 'defaultBeepLeadIn' => ['required', new Enum(BeepLeadIn::class)], - 'defaultEndSound' => 'required|in:triple,chime', - 'soundMode' => 'required|in:beep,voice', - 'volume' => 'required|numeric|min:0|max:1', - 'keepScreenOn' => 'boolean', + 'defaultEndSound' => 'required|in:triple,chime', + 'soundMode' => 'required|in:beep,voice', + 'volume' => 'required|numeric|min:0|max:1', + 'keepScreenOn' => 'boolean', ]); $settings = Setting::current(); $settings->default_beep_lead_in = $this->defaultBeepLeadIn; - $settings->default_end_sound = $this->defaultEndSound; - $settings->sound_mode = $this->soundMode; - $settings->volume = round((float) $this->volume, 2); - $settings->keep_screen_on = $this->keepScreenOn; + $settings->default_end_sound = $this->defaultEndSound; + $settings->sound_mode = $this->soundMode; + $settings->volume = round((float)$this->volume, 2); + $settings->keep_screen_on = $this->keepScreenOn; $settings->save(); @@ -64,4 +66,17 @@ public function save(): void $this->saved = true; } + + public function updateAndTest(string $soundMode): void + { + Log::info('Updating settings and testing voice mode...'); + $this->soundMode = $soundMode; + if ($soundMode === 'voice') { + \Nbucic\AudioTts\Facades\AudioTTS::speak('Where is Darth Vader now?', 1.0); +// $this->dispatch('playVoiceSound', reason: 'test'); + } else { + $this->dispatch('playBeepSound', sound: 'chime'); + } + Log::info('Updating settings and testing voice mode... [DONE]'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8f6fb84..bf60378 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ use App\Timer\TimerRunner; use Database\Seeders\DatabaseSeeder; use Illuminate\Support\ServiceProvider; +use Throwable; class AppServiceProvider extends ServiceProvider { @@ -21,14 +22,14 @@ public function register(): void */ public function boot(): void { - // On first install (empty programs table) seed the demo HIIT program. + // On the first installation (empty programs table) seed the demo HIIT program. // The guard inside DatabaseSeeder::run() makes this idempotent. - // Wrapped in try/catch: during `artisan migrate` the programs table + // Wrapped in try/catch: during `artisan migrate` the program table // does not yet exist when the service provider boots — swallow that // gracefully and let the seeder succeed on the next boot. try { (new DatabaseSeeder)->run(); - } catch (\Throwable $e) { + } catch (Throwable $e) { report($e); } } diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php new file mode 100644 index 0000000..f185763 --- /dev/null +++ b/app/Providers/NativeServiceProvider.php @@ -0,0 +1,42 @@ +> + */ + public function plugins(): array + { + return [ + AudioTTSServiceProvider::class, + + ]; + } +} diff --git a/composer.json b/composer.json index 94b5737..fef06c5 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "laravel/pail": "^1.2.5", "laravel/pint": "^1.27", "mockery/mockery": "^1.6", + "nbucic/audio-tts": "*@dev", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.0", "pestphp/pest-plugin-laravel": "^4.0", @@ -56,6 +57,7 @@ "@php artisan test" ], "build": [ + "@php artisan config:clear --ansi", "@php artisan native:package android" ], "post-autoload-dump": [ @@ -92,5 +94,11 @@ } }, "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "repositories": [ + { + "type": "path", + "url": "/home/nikola/projects/private/interval-timer-nativephp/packages/nbucic/audio-tts" + } + ] } diff --git a/composer.lock b/composer.lock index def5b74..d8d846c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ed46c3d9d7188821f6ff9a70722847be", + "content-hash": "bee163741fb2ea58eac28c450d281d22", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -135,6 +190,56 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -508,6 +613,78 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "endroid/qr-code", + "version": "6.1.3", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.4" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.3", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.1.3" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2026-02-05T07:01:58+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -2251,19 +2428,20 @@ }, { "name": "nativephp/mobile", - "version": "3.2.2", + "version": "3.2.3", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "866a6dd9896cfdee3f33bfb3a95d568711a6ff02" + "reference": "adbf8f78fd52c7e74c1dfc82f36b44b61357d3b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/866a6dd9896cfdee3f33bfb3a95d568711a6ff02", - "reference": "866a6dd9896cfdee3f33bfb3a95d568711a6ff02", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/adbf8f78fd52c7e74c1dfc82f36b44b61357d3b6", + "reference": "adbf8f78fd52c7e74c1dfc82f36b44b61357d3b6", "shasum": "" }, "require": { + "endroid/qr-code": "^6.1.3", "ext-dom": "*", "ext-simplexml": "*", "guzzlehttp/guzzle": "^7.9", @@ -2276,7 +2454,6 @@ "workerman/workerman": "^4.1" }, "require-dev": { - "endroid/qr-code": "^5.0", "larastan/larastan": "^2.0", "laravel/pint": "^1.0", "nunomaduro/collision": "^7.9", @@ -2328,9 +2505,9 @@ ], "support": { "issues": "https://github.com/NativePHP/mobile-air/issues", - "source": "https://github.com/NativePHP/mobile-air/tree/3.2.2" + "source": "https://github.com/NativePHP/mobile-air/tree/3.2.3" }, - "time": "2026-04-04T20:00:18+00:00" + "time": "2026-04-10T19:30:15+00:00" }, { "name": "nesbot/carbon", @@ -7329,6 +7506,56 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "nbucic/audio-tts", + "version": "dev-fix/tts-wakelock-caching", + "dist": { + "type": "path", + "url": "/home/nikola/projects/private/interval-timer-nativephp/packages/nbucic/audio-tts", + "reference": "0d098f04f0cdd89f14f1b895f5a7e8a5c20e7556" + }, + "require": { + "nativephp/mobile": "^3.0", + "php": "^8.2" + }, + "require-dev": { + "pestphp/pest": "^3.0" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Nbucic\\AudioTts\\AudioTTSServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Nbucic\\AudioTts\\": "src/" + } + }, + "scripts": { + "test": [ + "pest" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Your Name", + "email": "you@example.com" + } + ], + "description": "An Audio TTS plugin for Android", + "transport-options": { + "relative": false + } + }, { "name": "nunomaduro/collision", "version": "v8.9.2", @@ -9787,7 +10014,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "nbucic/audio-tts": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/package-lock.json b/package-lock.json index 6dbefb1..a24afc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "axios": ">=1.11.0 <=1.14.0", + "axios": "1.15.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.0.0", "tailwindcss": "^4.0.0", @@ -101,9 +101,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -120,9 +120,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -130,9 +130,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -147,9 +147,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -164,9 +164,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -181,9 +181,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -198,9 +198,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -215,13 +215,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -232,13 +235,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -249,13 +255,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -266,13 +275,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -283,13 +295,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -300,13 +315,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -317,9 +335,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -334,9 +352,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -344,16 +362,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -368,9 +388,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -385,9 +405,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -524,6 +544,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -541,6 +564,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -558,6 +584,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -575,6 +604,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -732,9 +764,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -994,9 +1026,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -1361,6 +1393,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1382,6 +1417,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1403,6 +1441,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1424,6 +1465,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1562,9 +1606,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -1611,14 +1655,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1627,21 +1671,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/rxjs": { @@ -1743,14 +1787,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1777,16 +1821,16 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", - "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { diff --git a/package.json b/package.json index 28b2977..78710ad 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "axios": ">=1.11.0 <=1.14.0", + "axios": "1.15.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.0.0", "tailwindcss": "^4.0.0", diff --git a/packages/nbucic/audio-tts/.gitignore b/packages/nbucic/audio-tts/.gitignore new file mode 100644 index 0000000..4deac1a --- /dev/null +++ b/packages/nbucic/audio-tts/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/node_modules/ +.DS_Store +*.log \ No newline at end of file diff --git a/packages/nbucic/audio-tts/README.md b/packages/nbucic/audio-tts/README.md new file mode 100644 index 0000000..20504fd --- /dev/null +++ b/packages/nbucic/audio-tts/README.md @@ -0,0 +1,37 @@ +# AudioTTS Plugin for NativePHP Mobile + +An Audio TTS plugin for Android + +## Installation + +```bash +composer require nbucic/audio-tts +``` + +## Usage + +```php +use Nbucic\AudioTts\Facades\AudioTTS; + +// Execute functionality +$result = AudioTTS::execute(['option1' => 'value']); + +// Get status +$status = AudioTTS::getStatus(); +``` + +## Listening for Events + +```php +use Livewire\Attributes\On; + +#[On('native:Nbucic\AudioTts\Events\AudioTTSCompleted')] +public function handleAudioTTSCompleted($result, $id = null) +{ + // Handle the event +} +``` + +## License + +MIT \ No newline at end of file diff --git a/packages/nbucic/audio-tts/composer.json b/packages/nbucic/audio-tts/composer.json new file mode 100644 index 0000000..5c2030f --- /dev/null +++ b/packages/nbucic/audio-tts/composer.json @@ -0,0 +1,42 @@ +{ + "name": "nbucic/audio-tts", + "description": "An Audio TTS plugin for Android", + "type": "nativephp-plugin", + "license": "MIT", + "authors": [ + { + "name": "Your Name", + "email": "you@example.com" + } + ], + "require": { + "php": "^8.2", + "nativephp/mobile": "^3.0" + }, + "autoload": { + "psr-4": { + "Nbucic\\AudioTts\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Nbucic\\AudioTts\\AudioTTSServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "require-dev": { + "pestphp/pest": "^3.0" + }, + "scripts": { + "test": "pest" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/nativephp.json b/packages/nbucic/audio-tts/nativephp.json new file mode 100644 index 0000000..e756153 --- /dev/null +++ b/packages/nbucic/audio-tts/nativephp.json @@ -0,0 +1,73 @@ +{ + "name": "nbucic/audio-tts", + "version": "1.0.0", + "description": "An Audio TTS plugin for Android", + "namespace": "AudioTTS", + "keywords": [], + "category": "utilities", + "license": "MIT", + "pricing": { + "type": "free" + }, + "author": { + "name": "Nikola Bucić", + "email": "nikola.bucic@gmail.com", + "url": "https://github.com/nbucic" + }, + "homepage": "", + "repository": "", + "funding": [], + "platforms": [ + "android", + "ios" + ], + "icon": "resources/icon.png", + "screenshots": [], + "bridge_functions": [ + { + "name": "AudioTTS.Speak", + "android": "com.nbucic.plugins.audio_tts.AudioTTSFunctions.Speak", + "ios": "AudioTTSFunctions.Speak", + "description": "Speak text via Android TTS" + }, + { + "name": "AudioTTS.GetStatus", + "android": "com.nbucic.plugins.audio_tts.AudioTTSFunctions.GetStatus", + "ios": "AudioTTSFunctions.GetStatus", + "description": "Return TTS engine readiness state" + } + ], + "android": { + "min_version": 21, + "permissions": [], + "repositories": [], + "dependencies": { + "implementation": [] + }, + "activities": [], + "services": [], + "receivers": [], + "providers": [] + }, + "ios": { + "min_version": "15.0", + "permissions": [], + "repositories": [], + "dependencies": { + "swift_packages": [], + "pods": [] + } + }, + "assets": { + "android": [], + "ios": [] + }, + "secrets": [], + "events": [ + "Nbucic\\AudioTts\\Events\\AudioTTSCompleted" + ], + "service_provider": "Nbucic\\AudioTts\\AudioTTSServiceProvider", + "hooks": { + "copy_assets": "nativephp:audio-t-t-s:copy-assets" + } +} diff --git a/packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt b/packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt new file mode 100644 index 0000000..6efbb22 --- /dev/null +++ b/packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt @@ -0,0 +1,157 @@ +package com.nbucic.plugins.audio_tts + +import android.content.Context +import android.media.AudioManager +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.fragment.app.FragmentActivity +import com.nativephp.mobile.bridge.BridgeFunction +import com.nativephp.mobile.bridge.BridgeResponse + +private const val TAG = "AudioTTSEngine" + +/** + * Singleton TTS engine — initialized once at app startup, shared across all bridge functions. + * Mirrors the lifecycle pattern from react-native-tts (TextToSpeechModule). + */ +private object TTSEngine { + + private var tts: TextToSpeech? = null + + @Volatile + private var ready = false + + @Volatile + private var initialized = false + + @Synchronized + fun initialize(context: Context) { + if (initialized) return + initialized = true + + tts = TextToSpeech(context.applicationContext) { status -> + if (status == TextToSpeech.SUCCESS) { + val langResult = tts?.setLanguage(java.util.Locale.US) + if (langResult == TextToSpeech.LANG_MISSING_DATA || + langResult == TextToSpeech.LANG_NOT_SUPPORTED + ) { + Log.e(TAG, "Language not supported (result=$langResult)") + } else { + tts?.setPitch(0.9f) + tts?.setSpeechRate(0.85f) + ready = true + Log.d(TAG, "TTS engine ready") + attachProgressListener() + } + } else { + Log.e(TAG, "TTS init failed with status=$status") + } + } + } + + val isReady: Boolean get() = ready + + /** + * Speak text at the given volume (0.0–1.0). + * + * Uses QUEUE_ADD so phrases queue rather than cutting each other off. + * Uses Bundle params (API 21+) to set stream and volume — same approach as react-native-tts. + */ + fun speak(text: String, volume: Float = 1.0f): Boolean { + if (!ready) { + Log.w(TAG, "speak() called before engine ready — dropping: \"$text\"") + return false + } + + val utteranceId = text.hashCode().toString() + val params = Bundle().apply { + putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC) + putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.coerceIn(0f, 1f)) + } + + val result = tts?.speak(text, TextToSpeech.QUEUE_ADD, params, utteranceId) + return if (result == TextToSpeech.SUCCESS) { + Log.d(TAG, "speak() queued: \"$text\"") + true + } else { + Log.e(TAG, "speak() failed (result=$result) for: \"$text\"") + false + } + } + + private fun attachProgressListener() { + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String) { + Log.d(TAG, "onStart utteranceId=$utteranceId") + } + + override fun onDone(utteranceId: String) { + Log.d(TAG, "onDone utteranceId=$utteranceId") + } + + override fun onError(utteranceId: String) { + Log.e(TAG, "onError utteranceId=$utteranceId") + } + }) + } + + fun shutdown() { + tts?.stop() + tts?.shutdown() + tts = null + ready = false + initialized = false + Log.d(TAG, "TTS engine shut down") + } +} + +/** + * NativePHP bridge functions for the AudioTTS plugin. + * + * Each class is instantiated once at app startup by PluginBridgeFunctionRegistration, + * so TTSEngine.initialize() runs exactly once across all bridge function constructors. + */ +object AudioTTSFunctions { + + /** + * Speak text via Android TTS. + * + * Parameters: + * text (String, required) — the phrase to speak + * volume (Float, optional, default 1.0) — output volume 0.0–1.0 + */ + class Speak(private val activity: FragmentActivity) : BridgeFunction { + init { + TTSEngine.initialize(activity.applicationContext) + } + + override fun execute(parameters: Map): Map { + val text = parameters["text"] as? String + ?: return BridgeResponse.error("INVALID_PARAMS", "Missing required parameter: text") + val volume = (parameters["volume"] as? Number)?.toFloat() ?: 1.0f + + val queued = TTSEngine.speak(text, volume) + return BridgeResponse.success(mapOf("queued" to queued, "text" to text)) + } + } + + /** + * Return engine readiness state. + */ + class GetStatus(private val activity: FragmentActivity) : BridgeFunction { + init { + TTSEngine.initialize(activity.applicationContext) + } + + override fun execute(parameters: Map): Map { + return BridgeResponse.success( + mapOf( + "ready" to TTSEngine.isReady, + "version" to "1.0.0" + ) + ) + } + } +} diff --git a/packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php b/packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..143b9b4 --- /dev/null +++ b/packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php @@ -0,0 +1,61 @@ +## nbucic/audio-tts + +An Audio TTS plugin for Android + +### Installation + +```bash +composer require nbucic/audio-tts +``` + +### PHP Usage (Livewire/Blade) + +Use the `AudioTTS` facade: + +@verbatim + +use Nbucic\AudioTts\Facades\AudioTTS; + +// Execute the plugin functionality +$result = AudioTTS::execute(['option1' => 'value']); + +// Get the current status +$status = AudioTTS::getStatus(); + +@endverbatim + +### Available Methods + +- `AudioTTS::execute()`: Execute the plugin functionality +- `AudioTTS::getStatus()`: Get the current status + +### Events + +- `AudioTTSCompleted`: Listen with `#[OnNative(AudioTTSCompleted::class)]` + +@verbatim + +use Native\Mobile\Attributes\OnNative; +use Nbucic\AudioTts\Events\AudioTTSCompleted; + +#[OnNative(AudioTTSCompleted::class)] +public function handleAudioTTSCompleted($result, $id = null) +{ + // Handle the event +} + +@endverbatim + +### JavaScript Usage (Vue/React/Inertia) + +@verbatim + +import { audioTTS } from '@nbucic/audio-tts'; + +// Execute the plugin functionality +const result = await audioTTS.execute({ option1: 'value' }); + +// Get the current status +const status = await audioTTS.getStatus(); + +@endverbatim \ No newline at end of file diff --git a/packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift b/packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift new file mode 100644 index 0000000..639b39e --- /dev/null +++ b/packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift @@ -0,0 +1,27 @@ +import Foundation + +enum AudioTTSFunctions { + + class Execute: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + // TODO: Implement your native functionality here + let option1 = parameters["option1"] as? String ?? "" + + // Example: Return success with data + return BridgeResponse.success(data: [ + "result": "executed", + "option1": option1 + ]) + } + } + + class GetStatus: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + // TODO: Return current status + return BridgeResponse.success(data: [ + "status": "ready", + "version": "1.0.0" + ]) + } + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/resources/js/audioTTS.js b/packages/nbucic/audio-tts/resources/js/audioTTS.js new file mode 100644 index 0000000..1261d9d --- /dev/null +++ b/packages/nbucic/audio-tts/resources/js/audioTTS.js @@ -0,0 +1,82 @@ +/** + * AudioTTS Plugin for NativePHP Mobile + * + * @example + * import { audioTTS } from '@nbucic/audio-tts'; + * + * // Execute functionality + * const result = await audioTTS.execute({ option1: 'value' }); + * + * // Get status + * const status = await audioTTS.getStatus(); + */ + +const baseUrl = '/_native/api/call'; + +/** + * Internal bridge call function + * @private + */ +async function bridgeCall(method, params = {}) { + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '' + }, + body: JSON.stringify({ method, params }) + }); + + const result = await response.json(); + + if (result.status === 'error') { + throw new Error(result.message || 'Native call failed'); + } + + const nativeResponse = result.data; + if (nativeResponse && nativeResponse.data !== undefined) { + return nativeResponse.data; + } + + return nativeResponse; +} + +/** + * Speak text via Android TTS. + * Falls back to Web Speech API when the native bridge is unavailable (browser dev). + * @param {string} text - The phrase to speak + * @param {number} [volume=1.0] - Output volume 0.0–1.0 + * @returns {Promise} + */ +export async function speak(text, volume = 1.0) { + try { + console.log(`[TTS SPEAK] text: ${text}, volume: ${volume}`); + return await bridgeCall('AudioTTS.Speak', { text, volume }); + } catch (e) { + if ('speechSynthesis' in window) { + const utt = new SpeechSynthesisUtterance(text); + utt.volume = volume; + speechSynthesis.speak(utt); + return { queued: true, text, fallback: 'webSpeech' }; + } + throw e; + } +} + +/** + * Get the current status + * @returns {Promise} + */ +export async function getStatus() { + return bridgeCall('AudioTTS.GetStatus'); +} + +/** + * AudioTTS namespace object + */ +export const audioTTS = { + speak, + getStatus +}; + +export default audioTTS; diff --git a/packages/nbucic/audio-tts/src/AudioTTS.php b/packages/nbucic/audio-tts/src/AudioTTS.php new file mode 100644 index 0000000..4c8233a --- /dev/null +++ b/packages/nbucic/audio-tts/src/AudioTTS.php @@ -0,0 +1,40 @@ +data ?? null; + } + } + + return null; + } + + /** + * Execute the plugin functionality + */ + public function speak(string $text, float $volume = 1.0): mixed + { + if (function_exists('nativephp_call')) { + $result = nativephp_call('AudioTTS.Speak', json_encode(['text' => $text, 'volume' => $volume])); + + if ($result) { + $decoded = json_decode($result); + return $decoded->data ?? null; + } + } + + return null; + } +} diff --git a/packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php b/packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php new file mode 100644 index 0000000..30e84e8 --- /dev/null +++ b/packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php @@ -0,0 +1,26 @@ +app->singleton(AudioTTS::class, function () { + return new AudioTTS(); + }); + } + + public function boot(): void + { + // Register plugin hook commands + if ($this->app->runningInConsole()) { + $this->commands([ + CopyAssetsCommand::class, + ]); + } + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php b/packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php new file mode 100644 index 0000000..a37cdf9 --- /dev/null +++ b/packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php @@ -0,0 +1,65 @@ +isAndroid()) { + $this->copyAndroidAssets(); + } + + if ($this->isIos()) { + $this->copyIosAssets(); + } + + return self::SUCCESS; + } + + /** + * Copy assets for Android build + */ + protected function copyAndroidAssets(): void + { + // Example: Copy a TensorFlow Lite model to Android assets + // $this->copyToAndroidAssets('model.tflite', 'model.tflite'); + + // Example: Download a model if not present locally + // $modelPath = $this->pluginPath() . '/resources/model.tflite'; + // $this->downloadIfMissing( + // 'https://example.com/model.tflite', + // $modelPath + // ); + // $this->copyToAndroidAssets('model.tflite', 'model.tflite'); + + $this->info('Android assets copied for AudioTTS'); + } + + /** + * Copy assets for iOS build + */ + protected function copyIosAssets(): void + { + // Example: Copy a Core ML model to iOS bundle + // $this->copyToIosBundle('model.mlmodelc', 'model.mlmodelc'); + + $this->info('iOS assets copied for AudioTTS'); + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php b/packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php new file mode 100644 index 0000000..945030a --- /dev/null +++ b/packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php @@ -0,0 +1,16 @@ +in('.'); \ No newline at end of file diff --git a/packages/nbucic/audio-tts/tests/PluginTest.php b/packages/nbucic/audio-tts/tests/PluginTest.php new file mode 100644 index 0000000..bf364de --- /dev/null +++ b/packages/nbucic/audio-tts/tests/PluginTest.php @@ -0,0 +1,221 @@ +pluginPath = dirname(__DIR__); + $this->manifestPath = $this->pluginPath . '/nativephp.json'; +}); + +describe('Plugin Manifest', function () { + it('has a valid nativephp.json file', function () { + expect(file_exists($this->manifestPath))->toBeTrue(); + + $content = file_get_contents($this->manifestPath); + $manifest = json_decode($content, true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + }); + + it('has required fields', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest)->toHaveKeys(['name', 'namespace', 'bridge_functions']); + expect($manifest['name'])->toBe('nbucic/audio-tts'); + expect($manifest['namespace'])->toBe('AudioTTS'); + }); + + it('has valid bridge functions', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['bridge_functions'])->toBeArray(); + + foreach ($manifest['bridge_functions'] as $function) { + expect($function)->toHaveKeys(['name']); + expect($function)->toHaveAnyKeys(['android', 'ios']); + } + }); + + it('has valid marketplace metadata', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + // Optional but recommended for marketplace + if (isset($manifest['keywords'])) { + expect($manifest['keywords'])->toBeArray(); + } + + if (isset($manifest['category'])) { + expect($manifest['category'])->toBeString(); + } + + if (isset($manifest['platforms'])) { + expect($manifest['platforms'])->toBeArray(); + foreach ($manifest['platforms'] as $platform) { + expect($platform)->toBeIn(['android', 'ios']); + } + } + }); +}); + +describe('Native Code', function () { + it('has Android Kotlin file', function () { + $kotlinFile = $this->pluginPath . '/resources/android/AudioTTSFunctions.kt'; + + expect(file_exists($kotlinFile))->toBeTrue(); + + $content = file_get_contents($kotlinFile); + expect($content)->toContain('package com.nbucic.plugins.audio_tts'); + expect($content)->toContain('object AudioTTSFunctions'); + expect($content)->toContain('BridgeFunction'); + }); + + it('has iOS Swift file', function () { + $swiftFile = $this->pluginPath . '/resources/ios/AudioTTSFunctions.swift'; + + expect(file_exists($swiftFile))->toBeTrue(); + + $content = file_get_contents($swiftFile); + expect($content)->toContain('enum AudioTTSFunctions'); + expect($content)->toContain('BridgeFunction'); + }); + + it('has matching bridge function classes in native code', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + $kotlinFile = $this->pluginPath . '/resources/android/AudioTTSFunctions.kt'; + $swiftFile = $this->pluginPath . '/resources/ios/AudioTTSFunctions.swift'; + + $kotlinContent = file_get_contents($kotlinFile); + $swiftContent = file_get_contents($swiftFile); + + foreach ($manifest['bridge_functions'] as $function) { + // Extract class name from the function reference + if (isset($function['android'])) { + $parts = explode('.', $function['android']); + $className = end($parts); + expect($kotlinContent)->toContain("class {$className}"); + } + + if (isset($function['ios'])) { + $parts = explode('.', $function['ios']); + $className = end($parts); + expect($swiftContent)->toContain("class {$className}"); + } + } + }); +}); + +describe('PHP Classes', function () { + it('has service provider', function () { + $file = $this->pluginPath . '/src/AudioTTSServiceProvider.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Nbucic\AudioTts'); + expect($content)->toContain('class AudioTTSServiceProvider'); + }); + + it('has facade', function () { + $file = $this->pluginPath . '/src/Facades/AudioTTS.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Nbucic\AudioTts\Facades'); + expect($content)->toContain('class AudioTTS extends Facade'); + }); + + it('has main implementation class', function () { + $file = $this->pluginPath . '/src/AudioTTS.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Nbucic\AudioTts'); + expect($content)->toContain('class AudioTTS'); + }); +}); + +describe('Composer Configuration', function () { + it('has valid composer.json', function () { + $composerPath = $this->pluginPath . '/composer.json'; + expect(file_exists($composerPath))->toBeTrue(); + + $content = file_get_contents($composerPath); + $composer = json_decode($content, true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + expect($composer['type'])->toBe('nativephp-plugin'); + expect($composer['extra']['nativephp']['manifest'])->toBe('nativephp.json'); + }); +}); + +describe('Lifecycle Hooks', function () { + it('has valid hooks configuration', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + if (isset($manifest['hooks'])) { + expect($manifest['hooks'])->toBeArray(); + + $validHooks = ['pre_compile', 'post_compile', 'copy_assets', 'post_build']; + foreach (array_keys($manifest['hooks']) as $hook) { + expect($hook)->toBeIn($validHooks); + } + } + }); + + it('has copy_assets hook command', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['hooks']['copy_assets'] ?? null)->not->toBeNull(); + + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + expect(file_exists($commandFile))->toBeTrue(); + }); + + it('copy_assets command extends NativePluginHookCommand', function () { + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + $content = file_get_contents($commandFile); + + expect($content)->toContain('extends NativePluginHookCommand'); + expect($content)->toContain('use Native\Mobile\Plugins\Commands\NativePluginHookCommand'); + }); + + it('copy_assets command has correct signature', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + $expectedSignature = $manifest['hooks']['copy_assets']; + + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + $content = file_get_contents($commandFile); + + expect($content)->toContain('$signature = \'' . $expectedSignature . '\''); + }); + + it('copy_assets command has platform-specific methods', function () { + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + $content = file_get_contents($commandFile); + + // Should check for platform + expect($content)->toContain('$this->isAndroid()'); + expect($content)->toContain('$this->isIos()'); + }); + + it('has valid assets configuration', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + // Assets are at top level with android/ios nested inside + if (isset($manifest['assets'])) { + expect($manifest['assets'])->toBeArray(); + + if (isset($manifest['assets']['android'])) { + expect($manifest['assets']['android'])->toBeArray(); + } + + if (isset($manifest['assets']['ios'])) { + expect($manifest['assets']['ios'])->toBeArray(); + } + } + }); +}); \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index dae0582..3ab345b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -110,6 +110,27 @@ document.addEventListener('alpine:init', () => { this._wakeLock = null; }, })); + Alpine.data('settingsSounds', () => ({ + soundMode: 'beep', + volume: 0.8, + + init() { + console.log('settings sounds'); + this.soundMode = this.$wire.soundMode; + this.volume = this.$wire.volume; + this.audio = initAudio(1); + + this.$wire.on('playBeepSound', ({sound}) => { + console.log('DEMO'); + debugger; + if (sound === 'triple') { + this.audio.tripleBeep(); + } else { + this.audio.chime(); + } + }); + } + })) }); // Alpine is automatically started by Livewire v3. diff --git a/resources/js/audio.js b/resources/js/audio.js index b3cd545..683d54e 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -1,5 +1,7 @@ // noinspection JSUnresolvedReference +import audioTTS from "../../packages/nbucic/audio-tts/resources/js/audioTTS.js"; + /** * Audio engine for the interval timer. * @@ -85,39 +87,7 @@ export function initAudio(volume = 0.8) { * Android Logcat (via WebChromeClient console forwarding) and DevTools. */ function speak(text) { - // Android native bridge (requires APK rebuilt with TTSBridge.kt) - if (window.AndroidTTS && typeof window.AndroidTTS.speak === 'function') { - console.log('[TTS] speak(): AndroidTTS bridge -> "' + text + '"'); - window.AndroidTTS.speak(text); - return; - } - - // Web Speech API fallback (browser dev / APK without TTSBridge compiled in) - if ('speechSynthesis' in window) { - const voices = speechSynthesis.getVoices(); - const voice = voices.find(v => v.lang.startsWith('en-')) - || voices.find(v => v.lang.startsWith('en')); - console.log('[TTS] speak(): speechSynthesis path' - + ', text="' + text + '"' - + ', voices=' + voices.length - + ', voice=' + (voice ? voice.name + ' (' + voice.lang + ')' : 'default')); - - const utt = new SpeechSynthesisUtterance(text); - utt.pitch = 0.9; - utt.rate = 0.85; - utt.volume = volume; - if (voice) utt.voice = voice; - utt.onerror = (e) => { - console.error('[TTS] SpeechSynthesisUtterance error: ' + e.error - + ' for "' + text + '"'); - }; - speechSynthesis.speak(utt); - return; - } - - console.warn('[TTS] speak(): no TTS available' - + ' (no AndroidTTS bridge, no speechSynthesis)' - + ', text="' + text + '"'); + return audioTTS.speak(text); } return { diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 3398139..e47dd4a 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,5 +1,6 @@ @php use App\Enum\BeepLeadIn; @endphp -
+

Settings

@@ -7,7 +8,11 @@ wire:click="save" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold px-4 py-2 rounded-xl transition-colors" > - @if($saved) Saved ✓ @else Save @endif + @if($saved) + Saved ✓ + @else + Save + @endif
@@ -23,7 +28,7 @@ class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold px-4 py-2

Sound Mode

+ >3s + + >5s +
@@ -113,12 +122,14 @@ class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors wire:click="$set('defaultEndSound', 'triple')" class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors {{ $defaultEndSound === 'triple' ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400' }}" - >Triple + >Triple + + >Chime +
diff --git a/tmp/MainActivity.kt b/tmp/MainActivity.kt new file mode 100644 index 0000000..323f3a8 --- /dev/null +++ b/tmp/MainActivity.kt @@ -0,0 +1,1044 @@ +package com.nativephp.mobile.ui + +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.os.Bundle +import android.os.Looper +import android.os.Handler +import android.util.Log +import android.webkit.CookieManager +import androidx.fragment.app.FragmentActivity +import androidx.activity.compose.setContent +import com.nativephp.mobile.bridge.PHPBridge +import com.nativephp.mobile.bridge.PHPQueueWorker +import com.nativephp.mobile.bridge.LaravelEnvironment +import com.nativephp.mobile.bridge.registerBridgeFunctions +import com.nativephp.mobile.network.WebViewManager +import android.view.ViewGroup +import android.webkit.WebView +import androidx.activity.addCallback +import com.nativephp.mobile.utils.NativeActionCoordinator +import com.nativephp.mobile.utils.WebViewProvider +import com.nativephp.mobile.security.LaravelCookieStore +import com.nativephp.mobile.lifecycle.NativePHPLifecycle +import java.io.File +import java.net.URL +import android.webkit.WebChromeClient +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.ime +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.graphics.Insets +import kotlinx.coroutines.launch + +class MainActivity : FragmentActivity(), WebViewProvider { + private lateinit var webView: WebView + private val phpBridge = PHPBridge(this) + private lateinit var laravelEnv: LaravelEnvironment + private lateinit var webViewManager: WebViewManager + private lateinit var coord: NativeActionCoordinator + private var pendingDeepLink: String? = null + private var hotReloadWatcherThread: Thread? = null + private var queueWorker: PHPQueueWorker? = null + private var shouldStopWatcher = false + private var pendingInsets: Insets? = null + private var showSplash by mutableStateOf(true) + + // Status bar style configuration - replaced during build + private val statusBarStyle = "auto" + + companion object { + // Static instance holder for accessing MainActivity from other activities + var instance: MainActivity? = null + private set + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + instance = this + + // Android 15 edge-to-edge compatibility fix + WindowCompat.setDecorFitsSystemWindows(window, false) + + // Configure status bar icon colors + configureStatusBar() + + // Apply window insets - inject as CSS variables for web content + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + pendingInsets = systemBars + + // Inject CSS custom properties into WebView if ready + if (::webViewManager.isInitialized) { + injectSafeAreaInsets(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + } + + // Detect keyboard visibility and inject class into WebView + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + if (::webViewManager.isInitialized) { + injectKeyboardVisibility(imeVisible) + } + + insets + } + + // Initialize WebView before setContent so it's available for composition + webView = WebView(this).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.mediaPlaybackRequiresUserGesture = false + } + + LaravelCookieStore.init(applicationContext) + + // Register bridge functions early, before PHP code can execute + Log.d("MainActivity", "🔌 Registering bridge functions...") + registerBridgeFunctions(this, applicationContext) + Log.d("MainActivity", "✅ Bridge functions registered") + + handleDeepLinkIntent(intent) + + // Set up Compose UI + setContent { + MainScreen() + } + + initializeEnvironmentAsync { + // Setup WebView and managers FIRST + webViewManager = WebViewManager(this, webView, phpBridge) + webViewManager.setup() + coord = NativeActionCoordinator.install(this) + + // Add JavaScript interface for drawer control + webView.addJavascriptInterface(AndroidBridge(), "AndroidBridge") + + // Inject safe area insets BEFORE loading any URL to prevent content shift + pendingInsets?.let { + injectSafeAreaInsets(it.left, it.top, it.right, it.bottom) + } + + // NOW load the URL after WebView is fully configured + val target = pendingDeepLink ?: LaravelEnvironment.getStartURL(this) + val fullUrl = "http://127.0.0.1$target" + Log.d("DeepLink", "🚀 Loading final URL after WebView setup: $fullUrl") + webView.loadUrl(fullUrl) + + pendingDeepLink = null + + // Hide splash screen after URL is loaded + showSplash = false + + // Start hot reload watcher AFTER Laravel environment is initialized + startHotReloadWatcher() + injectJavaScript(webView) + } + + onBackPressedDispatcher.addCallback(this) { + if (webView.canGoBack()) { + webView.goBack() + } else { + finish() + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Log.d("MainActivity", "🌀 Config changed: orientation = ${newConfig.orientation}") + + // Re-inject safe area insets on orientation change + pendingInsets?.let { + injectSafeAreaInsets(it.left, it.top, it.right, it.bottom) + } + + // Reconfigure status bar on theme change + if ((newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) != 0) { + configureStatusBar() + } + } + + /** + * Configure status bar and navigation bar colors and icon appearance based on config + * - auto: Detect from system theme (light icons in dark mode, dark icons in light mode) + * - light: Always use light/white icons + * - dark: Always use dark icons + * + * For edge-to-edge mode, system bars are transparent to allow content to draw behind them + */ + @Suppress("DEPRECATION") + private fun configureStatusBar() { + val windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) + + // Make status bar and navigation bar transparent for edge-to-edge + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + + when (statusBarStyle) { + "auto" -> { + // Auto-detect from system theme + val isSystemDarkMode = (resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + + // Light status/nav bars (dark icons) for light theme + // Dark status/nav bars (light icons) for dark theme + windowInsetsController.isAppearanceLightStatusBars = !isSystemDarkMode + windowInsetsController.isAppearanceLightNavigationBars = !isSystemDarkMode + + Log.d("StatusBar", "🎨 System bars style: auto (system ${if (isSystemDarkMode) "dark" else "light"} mode)") + Log.d("StatusBar", "🎨 Using ${if (!isSystemDarkMode) "dark" else "light"} icons with transparent background") + } + "light" -> { + // Light/white icons (for dark backgrounds) + windowInsetsController.isAppearanceLightStatusBars = false + windowInsetsController.isAppearanceLightNavigationBars = false + + Log.d("StatusBar", "🎨 System bars style: light (white icons with transparent background)") + } + "dark" -> { + // Dark icons (for light backgrounds) + windowInsetsController.isAppearanceLightStatusBars = true + windowInsetsController.isAppearanceLightNavigationBars = true + + Log.d("StatusBar", "🎨 System bars style: dark (dark icons with transparent background)") + } + else -> { + Log.w("StatusBar", "⚠️ Unknown status bar style: $statusBarStyle, defaulting to auto") + // Default to auto + val isSystemDarkMode = (resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + windowInsetsController.isAppearanceLightStatusBars = !isSystemDarkMode + windowInsetsController.isAppearanceLightNavigationBars = !isSystemDarkMode + } + } + } + + private fun initializeEnvironmentAsync(onReady: () -> Unit) { + Thread { + Log.d("LaravelInit", "Starting async Laravel extraction...") + laravelEnv = LaravelEnvironment(this) + laravelEnv.initialize() + + Log.d("LaravelInit", "Laravel environment ready") + + // Check runtime mode from bundle_meta.json + val runtimeMode = LaravelEnvironment.getRuntimeMode(this) + Log.d("LaravelInit", "Runtime mode: $runtimeMode") + + if (runtimeMode == "classic") { + Log.d("LaravelInit", "Classic mode configured — skipping persistent runtime boot") + } else { + // Boot persistent PHP runtime BEFORE WebView loads + // This boots Laravel once — all subsequent requests dispatch through the live interpreter + val bootStart = System.currentTimeMillis() + val booted = phpBridge.bootPersistentRuntime() + val bootTime = System.currentTimeMillis() - bootStart + + if (booted) { + Log.d("LaravelInit", "Persistent runtime booted in ${bootTime}ms — requests will skip init/shutdown") + + // Start background queue worker after persistent runtime is ready + queueWorker = PHPQueueWorker(phpBridge).also { it.start() } + } else { + Log.w("LaravelInit", "Persistent runtime boot failed after ${bootTime}ms — falling back to classic mode") + } + } + + Handler(Looper.getMainLooper()).post { + onReady() + } + }.start() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleDeepLinkIntent(intent) + + // If deep link didn't fire but we have a notification URL, navigate via Inertia + if (intent.data == null) { + val notificationUrl = intent.getStringExtra("notification_url") + if (!notificationUrl.isNullOrEmpty()) { + navigateWithInertia(notificationUrl) + } + } + + // Post lifecycle event for plugins + intent.data?.let { uri -> + NativePHPLifecycle.post( + NativePHPLifecycle.Events.ON_NEW_INTENT, + mapOf("url" to uri.toString()) + ) + } + } + + override fun onResume() { + super.onResume() + NativePHPLifecycle.post(NativePHPLifecycle.Events.ON_RESUME) + } + + override fun onPause() { + super.onPause() + NativePHPLifecycle.post(NativePHPLifecycle.Events.ON_PAUSE) + } + + private fun handleDeepLinkIntent(intent: Intent?) { + // Check for notification URL extra (from local notification taps or foreground push) + val notificationUrl = intent?.getStringExtra("notification_url") + if (!notificationUrl.isNullOrEmpty()) { + Log.d("DeepLink", "🔔 Notification URL: $notificationUrl") + pendingDeepLink = notificationUrl + if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) { + val fullUrl = "http://127.0.0.1$notificationUrl" + Log.d("DeepLink", "🚀 Loading notification URL immediately: $fullUrl") + webView.loadUrl(fullUrl) + pendingDeepLink = null + } + return + } + + // Check for deep link URL from FCM data payload (background/killed push notifications) + val fcmUrl = intent?.getStringExtra("url") ?: intent?.getStringExtra("link") + if (!fcmUrl.isNullOrEmpty()) { + Log.d("DeepLink", "🔔 FCM deep link URL: $fcmUrl") + val uri = android.net.Uri.parse(fcmUrl) + val scheme = uri.scheme + val route = if (scheme != null && scheme != "http" && scheme != "https") { + val host = uri.host ?: "" + val path = uri.path ?: "" + val query = uri.query?.let { "?$it" } ?: "" + if (host.isNotEmpty()) "/$host$path$query" else "$path$query" + } else { + fcmUrl + } + pendingDeepLink = route + if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) { + val fullUrl = "http://127.0.0.1$route" + Log.d("DeepLink", "🚀 Loading FCM deep link immediately: $fullUrl") + webView.loadUrl(fullUrl) + pendingDeepLink = null + } + return + } + + val uri = intent?.data ?: return + Log.d("DeepLink", "🌐 Received deep link: $uri") + + // Check if this is an OAuth callback from nativephp:// scheme + if (uri.scheme == "nativephp") { + Log.d("OAuth", "🔐 OAuth callback detected from scheme: ${uri.scheme}") + Log.d("OAuth", "🔐 OAuth callback host: ${uri.host}") + Log.d("OAuth", "🔐 OAuth callback path: ${uri.path}") + Log.d("OAuth", "🔐 OAuth callback query: ${uri.query}") + + // Check for common OAuth parameters + val code = uri.getQueryParameter("code") + val state = uri.getQueryParameter("state") + val error = uri.getQueryParameter("error") + + if (code != null) { + Log.d("OAuth", "✅ OAuth authorization code received: ${code.take(10)}...") + } + if (state != null) { + Log.d("OAuth", "✅ OAuth state parameter: $state") + } + if (error != null) { + Log.e("OAuth", "❌ OAuth error received: $error") + } + } + + val query = uri.query + val laravelUrl = if (uri.scheme != "http" && uri.scheme != "https") { + // Custom scheme (e.g., myapp://profile/settings): treat host as first path segment + // This matches iOS behavior where the entire URI after scheme:// is the path + val host = uri.host ?: "" + val path = uri.path ?: "" + buildString { + if (host.isNotEmpty()) append("/$host") + if (path.isNotEmpty()) append(path) else if (host.isEmpty()) append("/") + if (!query.isNullOrBlank()) append("?$query") + } + } else { + // HTTP(S) app links: just use the path (host is the verified domain) + buildString { + append(uri.path ?: "/") + if (!query.isNullOrBlank()) append("?$query") + } + } + + Log.d("DeepLink", "📦 Saving deep link for later: $laravelUrl") + pendingDeepLink = laravelUrl + if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) { + // Only load immediately if both Laravel environment AND WebView are ready + val fullUrl = "http://127.0.0.1$laravelUrl" + Log.d("DeepLink", "🚀 Loading deep link immediately (app already running): $fullUrl") + webView.loadUrl(fullUrl) + pendingDeepLink = null + } else { + Log.d("DeepLink", "⏳ Deep link saved, waiting for app initialization to complete") + } + } + + + private fun initializeEnvironment() { + clearAllCookies() + laravelEnv = LaravelEnvironment(this) + laravelEnv.initialize() + + } + + fun clearAllCookies() { + val cookieManager = CookieManager.getInstance() + cookieManager.removeAllCookies(null) + cookieManager.flush() + Log.d("CookieInfo", "All cookies cleared") + } + + + override fun onDestroy() { + super.onDestroy() + instance = null + + // Post lifecycle event for plugins + NativePHPLifecycle.post(NativePHPLifecycle.Events.ON_DESTROY) + + // Clean up coordinator fragment to prevent memory leaks + if (::coord.isInitialized) { + supportFragmentManager.beginTransaction() + .remove(coord) + .commitNowAllowingStateLoss() + } + + if (::webViewManager.isInitialized) { + val chromeClient = webView.webChromeClient + if (chromeClient is WebChromeClient) { + chromeClient.onHideCustomView() + } + webViewManager.shutdown() + } + + // Stop hot reload watcher thread + shouldStopWatcher = true + hotReloadWatcherThread?.interrupt() + + // Stop background queue worker before persistent runtime shutdown + queueWorker?.stop() + + // Shutdown persistent runtime before cleanup + if (phpBridge.isPersistentMode()) { + phpBridge.shutdownPersistentRuntime() + } + + laravelEnv.cleanup() + phpBridge.shutdown() + } + + override fun getWebView(): WebView { + return webView + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + // Post lifecycle event for each permission result + permissions.forEachIndexed { index, permission -> + val granted = grantResults.getOrNull(index) == PackageManager.PERMISSION_GRANTED + NativePHPLifecycle.post( + NativePHPLifecycle.Events.ON_PERMISSION_RESULT, + mapOf( + "permission" to permission, + "granted" to granted, + "requestCode" to requestCode + ) + ) + } + + when (requestCode) { + 1001 -> { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + Log.d("Permission", "✅ Location permission granted") + // Optionally re-trigger the location fetch + } else { + Log.e("Permission", "❌ Location permission denied") + } + } + 1002 -> { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + Log.d("Permission", "✅ Push notification permission granted") + } else { + Log.e("Permission", "❌ Push notification permission denied") + } + } + } + } + + private fun startHotReloadWatcher() { + if (!isDebugVersion()) return + + // Configure WebView for development - disable caching for hot reload + webView.settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE + + hotReloadWatcherThread = Thread { + val appStorageDir = File(filesDir.parent, "app_storage") + val reloadFile = File("${appStorageDir.absolutePath}/laravel/storage/framework/reload_signal.json") + var lastModified: Long = 0 + + while (!shouldStopWatcher && !Thread.currentThread().isInterrupted) { + try { + if (reloadFile.exists() && reloadFile.lastModified() > lastModified) { + lastModified = reloadFile.lastModified() + + runOnUiThread { + webView.stopLoading() + webView.clearCache(true) + webView.clearHistory() + webView.clearFormData() + + val currentUrl = webView.url ?: "http://127.0.0.1/" + val separator = if (currentUrl.contains("?")) "&" else "?" + val cacheBustUrl = "${currentUrl}${separator}_cb=${System.currentTimeMillis()}" + + Handler(Looper.getMainLooper()).postDelayed({ + webView.loadUrl(cacheBustUrl) + }, 100) + } + } + + Thread.sleep(500) + } catch (e: InterruptedException) { + break + } catch (e: Exception) { + Log.e("HotReload", "Watcher error: ${e.message}", e) + Thread.sleep(1000) + } + } + } + hotReloadWatcherThread?.start() + } + + private fun isDebugVersion(): Boolean { + return try { + val appStorageDir = File(filesDir.parent, "app_storage") + val versionFile = File(appStorageDir, "laravel/.version") + + if (versionFile.exists()) { + val version = versionFile.readText().trim().trim('"').trim('\'') + version.equals("DEBUG", ignoreCase = true) + } else { + false + } + } catch (e: Exception) { + false + } + } + + + private fun injectJavaScript(view: WebView) { + val jsCode = """ + (function() { + // Add platform identifier class + document.body.classList.add('nativephp-android'); + + // 🌐 Native event bridge + const listeners = {}; + + const Native = { + on: function(eventName, callback) { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + off: function(eventName, callback) { + if (listeners[eventName]) { + listeners[eventName] = listeners[eventName].filter(cb => cb !== callback); + } + }, + dispatch: function(eventName, payload) { + const cbs = listeners[eventName] || []; + cbs.forEach(cb => cb(payload, eventName)); + }, + openDrawer: function() { + if (window.AndroidBridge) { + window.AndroidBridge.openDrawer(); + } + } + }; + + window.Native = Native; + + document.addEventListener("native-event", function (e) { + // Normalize event names by removing leading backslashes + let eventName = e.detail.event.replace(/^(\\\\)+/, ''); + const payload = e.detail.payload; + + // Dispatch with normalized event name + Native.dispatch(eventName, payload); + + // Also dispatch to Livewire if available + if (window.Livewire && typeof window.Livewire.dispatch === 'function') { + window.Livewire.dispatch('native:' + eventName, payload); + } + }); + })(); + """ + view.evaluateJavascript(jsCode, null) + } + + private fun injectSafeAreaInsets(left: Int, top: Int, right: Int, bottom: Int) { + val density = resources.displayMetrics.density + val displayMetrics = resources.displayMetrics + + // Get current screen dimensions (rotated) + val currentWidthPx = (displayMetrics.widthPixels / density).toInt() + val currentHeightPx = (displayMetrics.heightPixels / density).toInt() + + // Determine natural (portrait) dimensions + // The smaller dimension is always the width in portrait orientation + val portraitWidthPx = minOf(currentWidthPx, currentHeightPx) + val portraitHeightPx = maxOf(currentWidthPx, currentHeightPx) + + val leftPx = (left / density).toInt() + var topPx = (top / density).toInt() + val rightPx = (right / density).toInt() + val bottomPx = (bottom / density).toInt() + + // Check if native top bar is present - if so, set top inset to 0 + // The native top bar already handles status bar spacing + val hasTopBar = NativeUIState.topBarData.value != null + if (hasTopBar) { + topPx = 0 + Log.d("SafeArea", "Native top bar detected - setting top inset to 0") + } + + // Get actual device orientation from Android Configuration + val isPortrait = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + + Log.d("SafeArea", "Device orientation: ${if (isPortrait) "Portrait" else "Landscape"}") + Log.d("SafeArea", "Current screen dimensions: ${currentWidthPx}x${currentHeightPx}") + Log.d("SafeArea", "Natural (portrait) dimensions: ${portraitWidthPx}x${portraitHeightPx}") + Log.d("SafeArea", "Injecting insets: top=${topPx}px, right=${rightPx}px, bottom=${bottomPx}px, left=${leftPx}px") + + // Inject CSS as early as possible - create a self-executing function that runs immediately + // and also sets up listeners for Livewire navigation to persist styles + val jsCode = """ + (function() { + function injectSafeAreaStyles() { + // Remove existing safe-area style to avoid duplicates + const existingStyle = document.getElementById('nativephp-safe-area-style'); + if (existingStyle) { + existingStyle.remove(); + } + + // Create style element with inset CSS variables and helper class + const style = document.createElement('style'); + style.id = 'nativephp-safe-area-style'; + style.setAttribute('data-nativephp-persist', 'true'); + style.textContent = ':root { --inset-top: ${topPx}px; --inset-right: ${rightPx}px; --inset-bottom: ${bottomPx}px; --inset-left: ${leftPx}px; } .nativephp-safe-area { ${if (isPortrait) "padding-top: var(--inset-top); padding-bottom: var(--inset-bottom);" else "padding-right: var(--inset-right); padding-left: var(--inset-left);"} }'; + + // Try to insert into head, or create head if it doesn't exist yet + if (!document.head) { + const head = document.createElement('head'); + if (document.documentElement) { + document.documentElement.insertBefore(head, document.documentElement.firstChild); + } + } + + if (document.head) { + // Insert at the BEGINNING of head for highest priority + if (document.head.firstChild) { + document.head.insertBefore(style, document.head.firstChild); + } else { + document.head.appendChild(style); + } + } + + // Also set CSS variables directly on documentElement for immediate availability + // These persist across Livewire navigate because html element is not replaced + if (document.documentElement) { + document.documentElement.style.setProperty('--inset-top', '${topPx}px'); + document.documentElement.style.setProperty('--inset-right', '${rightPx}px'); + document.documentElement.style.setProperty('--inset-bottom', '${bottomPx}px'); + document.documentElement.style.setProperty('--inset-left', '${leftPx}px'); + + // Add orientation class to HTML element for Tailwind targeting + document.documentElement.classList.remove('portrait', 'landscape'); + document.documentElement.classList.add('${if (isPortrait) "portrait" else "landscape"}'); + } + + console.log('SafeArea injected at ' + document.readyState + ': ${if (isPortrait) "portrait" else "landscape"}'); + } + + // Inject immediately + injectSafeAreaStyles(); + + // Re-inject when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectSafeAreaStyles); + } + + // IMPORTANT: Re-inject after Livewire navigation to persist styles + // Livewire can swap out the content during navigate: true transitions + document.addEventListener('livewire:navigated', function() { + console.log('Livewire navigated - re-injecting safe area styles'); + injectSafeAreaStyles(); + }); + + // Also listen for the older wire:navigate event (Livewire 2.x compatibility) + document.addEventListener('wire:navigate', function() { + console.log('Wire navigate - re-injecting safe area styles'); + injectSafeAreaStyles(); + }); + })(); + """ + webView.evaluateJavascript(jsCode, null) + } + + // Public function called by WebViewManager on page load + fun injectSafeAreaInsetsToWebView() { + pendingInsets?.let { + injectSafeAreaInsets(it.left, it.top, it.right, it.bottom) + } + } + + // Track keyboard visibility state to avoid redundant JS calls + private var lastKeyboardVisible: Boolean? = null + + private fun injectKeyboardVisibility(isVisible: Boolean) { + // Only inject if state actually changed + if (lastKeyboardVisible == isVisible) return + lastKeyboardVisible = isVisible + + // Update UI state so Compose components can react (e.g., hide bottom nav) + NativeUIState.setKeyboardVisible(isVisible) + + val jsCode = if (isVisible) { + "document.body.classList.add('keyboard-visible');" + } else { + "document.body.classList.remove('keyboard-visible');" + } + webView.evaluateJavascript(jsCode, null) + Log.d("Keyboard", "⌨️ Keyboard visibility changed: $isVisible") + } + + /** + * Extract path and query from URL, handling both full URLs and relative paths + * Supports Laravel route() helper output and relative paths + */ + private fun extractPath(url: String): String { + Log.d("Navigation", "📥 Received URL: $url") + + return try { + if (url.startsWith("http://") || url.startsWith("https://")) { + // Parse as full URL and extract path + query + val parsedUrl = URL(url) + // URL.getPath() returns empty string for root, not null - handle both cases + val path = if (parsedUrl.path.isNullOrEmpty()) "/" else parsedUrl.path + val query = parsedUrl.query + val result = if (query != null) "$path?$query" else path + Log.d("Navigation", "✅ Extracted path from full URL: $result") + result + } else if (url.startsWith("/")) { + // Already a path + Log.d("Navigation", "✅ Using path as-is: $url") + url + } else { + // Relative path, prepend / + val result = "/$url" + Log.d("Navigation", "✅ Converted relative to absolute: $result") + result + } + } catch (e: Exception) { + Log.e("Navigation", "❌ Error parsing URL: $url", e) + // Fallback: treat as relative path + val fallback = if (url.startsWith("/")) url else "/$url" + Log.d("Navigation", "🔄 Using fallback: $fallback") + fallback + } + } + + /** + * Navigate using Inertia router if available, otherwise fall back to direct navigation. + * This allows native edge component clicks to integrate with Inertia.js for SPA-like + * navigation while maintaining compatibility with non-Inertia apps. + */ + private fun navigateWithInertia(url: String) { + val path = extractPath(url) + Log.d("Navigation", "🚀 Navigating with Inertia check: $path") + + // Escape the path for JavaScript string (use double quotes to avoid issues with /) + val escapedPath = path.replace("\\", "\\\\").replace("\"", "\\\"") + + val jsCode = """ + (function() { + var path = "$escapedPath"; + console.log('[NativePHP] Navigation requested:', path); + + // Check if Inertia router is available + if (typeof window.router !== 'undefined' && typeof window.router.visit === 'function') { + console.log('[NativePHP] Using Inertia router.visit():', path); + window.router.visit(path); + } else { + console.log('[NativePHP] Inertia not available, using location.href'); + window.location.href = path; + } + })(); + """.trimIndent() + + webView.evaluateJavascript(jsCode, null) + } + + /** + * Main Compose UI screen with WebView, navigation, and overlays + * Side drawer wraps everything to avoid touch blocking issues + */ + @Composable + private fun MainScreen() { + Box(Modifier.fillMaxSize()) { + // Side drawer wraps the main content (correct ModalNavigationDrawer usage) + SideDrawerContent( + content = { + // Get FAB position from state + val fabData by NativeUIState.fabData + val fabPosition = when (fabData?.position?.lowercase()) { + "center" -> FabPosition.Center + "start" -> FabPosition.Start + else -> FabPosition.End // Default to end (bottom-right) + } + + // Scaffold provides standard Material3 layout with FAB support + // Configure for edge-to-edge by using zero content window insets + Scaffold( + topBar = { + NativeTopBar( + onMenuClick = { + Log.d("Navigation", "🍔 Menu button clicked - opening drawer") + }, + onNavigate = { url -> + Log.d("Navigation", "⚡ TopBar action navigation clicked") + navigateWithInertia(url) + } + ) + }, + bottomBar = { + BottomNavigationContent() + }, + floatingActionButton = { + NativeFab( + onNavigate = { url -> + Log.d("Navigation", "🖱️ FAB navigation clicked") + navigateWithInertia(url) + }, + onEvent = { eventName -> + Log.d("NativeEvent", "🖱️ FAB event dispatched: $eventName") + // Dispatch native event via JavaScript + val jsCode = """ + if (window.Native) { + window.Native.dispatch('$eventName', {}); + } + """.trimIndent() + webView.evaluateJavascript(jsCode, null) + } + ) + }, + floatingActionButtonPosition = fabPosition, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { paddingValues -> + // Main content: WebView only + // Use paddingValues to respect TopBar and BottomNav heights + // IMPORTANT: Add IME (keyboard) inset padding so content isn't hidden behind keyboard + + AndroidView( + factory = { webView }, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .windowInsetsPadding(WindowInsets.ime), + update = { view -> + // Force layout recalculation when Compose size changes + // This ensures viewport units (100vh, 100vw) work correctly + view.requestLayout() + } + ) + } + } + ) + + // Splash overlay with fade animation (full screen, no insets) + AnimatedVisibility( + visible = showSplash, + exit = fadeOut(animationSpec = tween(300)) + ) { + SplashScreen() + } + } + } + + /** + * Splash screen composable - shows custom image or fallback text + */ + @Composable + private fun SplashScreen() { + val splashResourceId = remember { + try { + resources.getIdentifier("splash", "drawable", packageName) + } catch (e: Exception) { + 0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + if (splashResourceId != 0) { + Image( + painter = painterResource(id = splashResourceId), + contentDescription = "App splash screen", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + SplashText() + } + } + } + + @Composable + private fun SplashText() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + Text( + text = "Loading…", + fontSize = 16.sp, + color = Color.White, + modifier = Modifier.padding(bottom = 64.dp) + ) + } + } + + /** + * Bottom navigation composable + * Hides with animation when keyboard is visible to prevent layout conflicts + */ + @Composable + private fun BottomNavigationContent() { + val isKeyboardVisible by NativeUIState.isKeyboardVisible + val bottomNavData by NativeUIState.bottomNavData + + val systemInDarkMode = isSystemInDarkTheme() + val useDarkTheme = bottomNavData?.dark ?: systemInDarkMode + val colorScheme = if (useDarkTheme) darkColorScheme() else lightColorScheme() + + // Animate bottom nav visibility - slide down when keyboard opens + AnimatedVisibility( + visible = !isKeyboardVisible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(150) + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(150) + ) + ) { + MaterialTheme(colorScheme = colorScheme) { + NativeBottomNavigation( + onNavigate = { url -> + Log.d("Navigation", "🖱️ Bottom nav item clicked") + navigateWithInertia(url) + } + ) + } + } + } + + /** + * Side drawer composable - wraps main content in ModalNavigationDrawer + */ + @Composable + private fun SideDrawerContent(content: @Composable () -> Unit) { + val systemInDarkMode = isSystemInDarkTheme() + val sideNavData by NativeUIState.sideNavData + val useDarkTheme = sideNavData?.dark ?: systemInDarkMode + val colorScheme = if (useDarkTheme) darkColorScheme() else lightColorScheme() + + MaterialTheme(colorScheme = colorScheme) { + NativeSideDrawer( + onNavigate = { url -> + Log.d("Navigation", "🖱️ Side nav item clicked") + navigateWithInertia(url) + }, + onDrawerStateChange = { isOpen -> + Log.d("SideDrawer", "Drawer state changed: $isOpen") + }, + content = content + ) + } + } + + inner class AndroidBridge { + @android.webkit.JavascriptInterface + fun openDrawer() { + Log.d("AndroidBridge", "🖱️ openDrawer() called from JavaScript") + runOnUiThread { + // Check if we have side nav data first + val hasData = NativeUIState.sideNavData.value != null && + !NativeUIState.sideNavData.value?.children.isNullOrEmpty() + + if (!hasData) { + Log.w("AndroidBridge", "⚠️ Cannot open drawer - no side nav data available") + return@runOnUiThread + } + + if (NativeUIState.drawerScope == null) { + Log.e("AndroidBridge", "❌ drawerScope is null!") + return@runOnUiThread + } + if (NativeUIState.drawerState == null) { + Log.e("AndroidBridge", "❌ drawerState is null!") + return@runOnUiThread + } + + // Open drawer via Compose state + NativeUIState.drawerScope?.launch { + NativeUIState.drawerState?.open() + Log.d("AndroidBridge", "✅ Drawer opened!") + } + } + } + } + +} \ No newline at end of file diff --git a/tmp/TTSBridge.kt b/tmp/TTSBridge.kt new file mode 100644 index 0000000..a3d9a96 --- /dev/null +++ b/tmp/TTSBridge.kt @@ -0,0 +1,174 @@ +package com.nativephp.mobile.bridge + +import android.content.Context +import android.media.MediaPlayer +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import android.webkit.JavascriptInterface +import java.io.File +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +/** + * JavaScript interface that exposes Android TextToSpeech to the WebView. + * + * Fixed phrases are pre-synthesized to WAV files in the app cache directory + * on first use and replayed via MediaPlayer on subsequent calls, eliminating + * TTS engine startup latency during workouts. + * + * Cache TTL: CACHE_DAYS days. Stale or missing files are rebuilt automatically. + * + * Registered as window.AndroidTTS in the WebView. + * + * Threading notes: + * - @JavascriptInterface methods are called on a binder thread. + * - MediaPlayer playback is dispatched to the main thread via mainHandler. + * - TextToSpeech callbacks arrive on the main thread. + */ +class TTSBridge(private val context: Context) { + + private companion object { + const val TAG = "TTSBridge" + const val CACHE_DAYS = 3L + + val KNOWN_PHRASES = listOf( + "Done", "Go", "Next", "Get ready", + "3", "2", "1", + "Rest", "Work", "Prepare", + ) + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private var tts: TextToSpeech? = null + private val players = ConcurrentHashMap() + private var engineReady = false + + init { + tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + tts?.language = Locale.US + tts?.setPitch(0.9f) + tts?.setSpeechRate(0.85f) + engineReady = true + setupUtteranceListener() + prebuildCache() + Log.d(TAG, "[TTS] engine ready") + } else { + Log.e(TAG, "[TTS] engine init failed with status $status") + } + } + } + + private fun cacheFile(phrase: String): File { + val safeName = phrase.lowercase().replace(Regex("[^a-z0-9]"), "_") + return File(context.cacheDir, "tts_$safeName.wav") + } + + private fun isCacheStale(file: File): Boolean { + if (!file.exists()) return true + val cutoffMs = System.currentTimeMillis() - CACHE_DAYS * 86_400_000L + return file.lastModified() < cutoffMs + } + + private fun setupUtteranceListener() { + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onDone(utteranceId: String) { + val file = cacheFile(utteranceId) + if (file.exists() && file.length() > 0) { + try { + val player = MediaPlayer().apply { + setDataSource(file.absolutePath) + prepare() + } + players[utteranceId] = player + Log.d(TAG, "[TTS] cached and loaded player for: $utteranceId") + } catch (e: Exception) { + Log.e(TAG, "[TTS] failed to load MediaPlayer for $utteranceId: ${e.message}") + } + } + } + override fun onError(utteranceId: String) { + Log.e(TAG, "[TTS] synthesis error for: $utteranceId") + } + override fun onStart(utteranceId: String) {} + }) + } + + private fun prebuildCache() { + Log.d(TAG, "[TTS] prebuildCache: pre-building ${KNOWN_PHRASES.size} phrases") + KNOWN_PHRASES.forEach { phrase -> + val file = cacheFile(phrase) + if (isCacheStale(file)) { + Log.d(TAG, "[TTS] synthesizing to file: $phrase") + val params = Bundle() + tts?.synthesizeToFile(phrase, params, file, phrase) + } else { + try { + players[phrase] = MediaPlayer().apply { + setDataSource(file.absolutePath) + prepare() + } + Log.d(TAG, "[TTS] loaded cached player for: $phrase") + } catch (e: Exception) { + Log.e(TAG, "[TTS] corrupt cache for $phrase, re-synthesizing: ${e.message}") + file.delete() + val params = Bundle() + tts?.synthesizeToFile(phrase, params, file, phrase) + } + } + } + } + + @JavascriptInterface + fun speak(text: String) { + Log.d(TAG, "[TTS] speak() entry: \"$text\"") + + val player = players[text] + if (player != null) { + mainHandler.post { + try { + player.seekTo(0) + player.start() + Log.d(TAG, "[TTS] playing cached TTS: $text") + } catch (e: Exception) { + Log.w(TAG, "[TTS] cached player failed for '$text', falling back: ${e.message}") + players.remove(text) + speakLive(text) + } + } + return + } + + Log.d(TAG, "[TTS] cache miss for \"$text\", calling speakLive()") + speakLive(text) + } + + private fun speakLive(text: String) { + if (tts == null) { + Log.e(TAG, "[TTS] speakLive: tts is null, cannot speak \"$text\"") + return + } + if (engineReady) { + Log.d(TAG, "[TTS] live TTS: $text") + tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) + } else { + Log.w(TAG, "[TTS] engine not ready, dropping: $text") + } + } + + fun shutdown() { + mainHandler.post { + players.values.forEach { + try { it.release() } catch (_: Exception) {} + } + players.clear() + } + tts?.shutdown() + tts = null + engineReady = false + } +} diff --git a/tmp/WebViewManager.kt b/tmp/WebViewManager.kt new file mode 100644 index 0000000..6704983 --- /dev/null +++ b/tmp/WebViewManager.kt @@ -0,0 +1,584 @@ +package com.nativephp.mobile.network + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.webkit.* +import android.widget.Toast +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.content.pm.ActivityInfo +import android.app.Activity +import com.acsbendi.requestinspectorwebview.RequestInspectorWebViewClient +import com.nativephp.mobile.bridge.PHPBridge +import com.nativephp.mobile.ui.MainActivity +import com.nativephp.mobile.ui.NativeUIState +import org.json.JSONObject +import com.nativephp.mobile.security.LaravelSecurity +import com.nativephp.mobile.bridge.TTSBridge + +class WebViewManager( + private val context: Context, + private val webView: WebView, + private val phpBridge: PHPBridge +) { + private val TAG = "PHPMonitor" + private var fullscreenView: View? = null + private var customViewCallback: WebChromeClient.CustomViewCallback? = null + private val ttsBridge = TTSBridge(context) + + companion object { + var shared: WebViewManager? = null + } + + fun setup() { + configureWebViewSettings() + setupCookieManager() + setupWebViewClient() + setupJavaScriptInterfaces() + WebViewManager.shared = this // 👈 make this instance globally accessible + } + + private fun configureWebViewSettings() { + // Don't clear cache on every setup - let it persist for performance + // webView.clearCache(true) + // webView.clearHistory() + + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = true + allowContentAccess = true + loadsImagesAutomatically = true + blockNetworkImage = false + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + mediaPlaybackRequiresUserGesture = false // Allows autoplay + setSupportMultipleWindows(true) // Required for fullscreen + cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK // Prefer cache for faster loads + } + + WebView.setWebContentsDebuggingEnabled(true) + } + + private fun setupCookieManager() { + CookieManager.getInstance().apply { + setAcceptCookie(true) + setAcceptThirdPartyCookies(webView, true) + } + } + + private fun setupWebViewClient() { + webView.webChromeClient = createWebChromeClient() + webView.webViewClient = createCustomWebViewClient() + } + + private fun createWebChromeClient(): WebChromeClient { + return object : WebChromeClient() { + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + fullscreenView?.let { onHideCustomView() } + + fullscreenView = view + customViewCallback = callback + + (context as? Activity)?.let { activity -> + val decorView = activity.window.decorView as FrameLayout + decorView.addView(view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + webView.visibility = View.GONE + + (context as? Activity)?.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + override fun onHideCustomView() { + (context as? Activity)?.let { activity -> + val decorView = activity.window.decorView as FrameLayout + + fullscreenView?.let { decorView.removeView(it) } + fullscreenView = null + + webView.visibility = View.VISIBLE + + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + + customViewCallback?.onCustomViewHidden() + customViewCallback = null + } + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + Log.d( + "$TAG-Console", + "${consoleMessage.message()} -- From line ${consoleMessage.lineNumber()}" + ) + return true + } + } + } + + private fun createCustomWebViewClient(): WebViewClient { + return object : WebViewClient() { + private val requestInspector = RequestInspectorWebViewClient(webView) + private val phpHandler = PHPWebViewClient(phpBridge, context as MainActivity) + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val url = request.url.toString() + val method = request.method + Log.d("$TAG-DEBUG", "URL: $url, Method: $method") + Log.d(TAG, "⬆️ shouldOverrideUrlLoading: $url") + + // Handle system URL schemes (tel:, mailto:, sms:, geo:) - open with system handler + val scheme = request.url.scheme?.lowercase() + if (scheme in listOf("tel", "mailto", "sms", "geo")) { + Log.d("WebView", "📞 Intercepted system URL scheme: $url") + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("WebView", "No app can handle $scheme: links") + Toast.makeText(context, "No app can handle this link", Toast.LENGTH_SHORT).show() + } + return true + } + + if (url.startsWith("nativephp://")) { + Log.d("WebView", "🔗 Intercepted deep link inside WebView: $url") + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(context, "No app can handle this link", Toast.LENGTH_SHORT).show() + } + + return true // prevent WebView from loading it + } + + if ((url.startsWith("http://") || url.startsWith("https://")) && + !url.contains("127.0.0.1") && + !url.contains("localhost") && + request.isForMainFrame + ) { + // This is a navigation request to an external site - open in browser + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + view.context.startActivity(intent) + return true + } + + // Handle relative URLs (convert to php://) + if (url.startsWith("/")) { + val uri = request.url + val fullUrl = "http://127.0.0.1${uri.encodedPath}" + + (uri.encodedQuery?.let { "?$it" } ?: "") + + Log.d(TAG, "🛠️ Rewriting relative URL with query: $fullUrl") + view.loadUrl(fullUrl) + return true + } + + return false + } + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url.toString() + val method = request.method + + Log.d(TAG, "🔄 Intercepting $method request to $url") + + request.requestHeaders.forEach { (key, value) -> + Log.d("$TAG-Headers", "📋 $key: $value") + } + + val inspectorResponse = requestInspector.shouldInterceptRequest(view, request) + + if (url.startsWith("http://") && !url.contains(".") && !url.contains("127.0.0.1") && !url.contains("localhost")) { + val host = url.substring("http://".length).substringBefore("/") + val path = if (url.contains("/")) "/${url.substringAfter("/")}" else "/" + val correctedUrl = "http://127.0.0.1/$host$path" + + Log.d(TAG, "🔄 Correcting malformed URL from $url to $correctedUrl") + + // Create a modified request with the corrected URL + val correctedUri = Uri.parse(correctedUrl) + val correctedRequest = object : WebResourceRequest { + override fun getUrl(): Uri = correctedUri + override fun isForMainFrame(): Boolean = request.isForMainFrame + override fun isRedirect(): Boolean = request.isRedirect + override fun hasGesture(): Boolean = request.hasGesture() + override fun getMethod(): String = request.method + override fun getRequestHeaders(): Map = request.requestHeaders + } + + // Handle this corrected request normally + return shouldInterceptRequest(view, correctedRequest) + } + + if (!url.contains("127.0.0.1") && !url.contains("localhost")) { + // This is an external resource - let the WebView handle it directly + Log.d(TAG, "📡 External resource - passing to system: $url") + return null // Returning null lets the WebView load it normally + } + + // Allow Vite dev server (port 5173) to handle its own requests, including WebSocket upgrades for HMR + if (url.contains(":5173")) { + Log.d(TAG, "🔥 Vite dev server request - allowing native WebView handling: $url") + return null + } + + return when { + isStaticAssetExtension(url) || + url.contains("_assets") || + url.contains("/js/") || + url.contains("/css/") || + url.contains("/fonts/") || + url.contains("/images/") -> { + Log.d(TAG, "🖼️ Handling asset request") + phpHandler.handleAssetRequest(url, request.requestHeaders) + } + // Regular PHP requests + url.contains("127.0.0.1") -> { + Log.d(TAG, "🌐 Handling PHP request") + val postData = if (request.method.equals("POST", ignoreCase = true) || + request.method.equals("PUT", ignoreCase = true) || + request.method.equals("PATCH", ignoreCase = true)) { + val reqId = request.requestHeaders?.get("X-NativePHP-Req-Id") + if (reqId != null) { + phpBridge.consumePostData(reqId) + } else { + // Native form submission — try full URL first, then path only + var data = phpBridge.consumePostData(url) + if (data == null) { + val path = request.url.path ?: "/" + data = phpBridge.consumePostData(path) + } + data + } + } else null + phpHandler.handlePHPRequest(request, postData) + } + else -> { + Log.d(TAG, "↪️ Delegating to system handler: $url") + inspectorResponse + } + } + } + + override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) { + super.onPageStarted(view, url, favicon) + Log.d(TAG, "🚀 Page started loading: $url") + + // Inject safe area insets IMMEDIATELY when page starts loading + // This ensures CSS variables are available before DOM parsing + (context as? MainActivity)?.injectSafeAreaInsetsToWebView() + } + + /** + * Process response headers - for HTML and JSON responses to handle native UI updates + * from both page loads and AJAX requests + */ + private fun processResponseHeaders( + url: String, + response: WebResourceResponse?, + request: WebResourceRequest + ) { + if (response == null) { + return + } + + val isMainFrame = request.isForMainFrame + + // Get content type + val contentType = response.responseHeaders?.entries?.firstOrNull { + it.key.equals("content-type", ignoreCase = true) + }?.value ?: "" + + val isHtmlResponse = contentType.contains("text/html", ignoreCase = true) + val isJsonResponse = contentType.contains("application/json", ignoreCase = true) + + // Find x-native-ui header (case-insensitive) + val nativeUiHeader = response.responseHeaders?.entries?.firstOrNull { + it.key.equals("x-native-ui", ignoreCase = true) + }?.value + + // Process for HTML pages (main frame) or JSON responses (AJAX) + if (isHtmlResponse || isJsonResponse) { + if (nativeUiHeader != null) { + Log.d(TAG, "✅ x-native-ui header found (${if (isJsonResponse) "JSON" else "HTML"}): $nativeUiHeader") + NativeUIState.updateFromJson(nativeUiHeader) + } else if (isHtmlResponse && isMainFrame) { + // Only clear UI state if this is a main frame HTML response without the header + // Don't clear for JSON responses to avoid clearing UI on every API call + Log.d(TAG, "❌ x-native-ui header NOT in HTML main frame - clearing state") + NativeUIState.clearAll() + } + } else { + // Asset request - ignore completely to avoid false negatives + Log.d(TAG, "⏭️ Skipping x-native-ui check for asset: $url") + } + } + + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + Log.d(TAG, "✅ Page finished loading: $url") + + // Inject safe area insets again to ensure they're set + (context as? MainActivity)?.injectSafeAreaInsetsToWebView() + + // Inject JavaScript to capture form submissions and AJAX requests + injectJavaScript(view) + } + } + } + + + private fun injectJavaScript(view: WebView) { + val jsCode = """ + (function() { + // 🌐 Native event bridge + const listeners = {}; + + const Native = { + on: function(eventName, callback) { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + off: function(eventName, callback) { + if (listeners[eventName]) { + listeners[eventName] = listeners[eventName].filter(cb => cb !== callback); + } + }, + dispatch: function(eventName, payload) { + const cbs = listeners[eventName] || []; + cbs.forEach(cb => cb(payload, eventName)); + } + }; + + window.Native = Native; + + document.addEventListener("native-event", function (e) { + const eventName = e.detail.event; + const payload = e.detail.payload; + + window.Native.dispatch(eventName, payload); + + + }); + + // Unique request ID counter + var _nphpReqId = 0; + + // Capture form submissions — native form POSTs can't carry custom headers, + // so we store by URL for the fallback lookup in shouldInterceptRequest + document.addEventListener('submit', function(e) { + var form = e.target; + var method = form.method.toLowerCase(); + if (["post", "patch", "put"].includes(method)) { + var formData = new FormData(form); + var urlEncodedData = new URLSearchParams(); + for (var pair of formData.entries()) { + urlEncodedData.append(pair[0], pair[1]); + } + + var bodyStr = urlEncodedData.toString(); + // Store by URL — native form submissions don't support custom headers + AndroidPOST.logFormPostData(bodyStr, form.action); + } + }); + + // Capture XHR/AJAX requests + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + var originalXHRSetHeader = XMLHttpRequest.prototype.setRequestHeader; + + XMLHttpRequest.prototype.open = function(method, url) { + this._method = method; + this._url = url; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function(data) { + if (["post", "patch", "put"].includes(this._method.toLowerCase()) && data) { + var reqId = 'nphp_' + (++_nphpReqId) + '_' + Date.now(); + AndroidPOST.logPostData(String(data), this._url, "", reqId); + originalXHRSetHeader.call(this, 'X-NativePHP-Req-Id', reqId); + } + return originalXHRSend.apply(this, arguments); + }; + + // Capture fetch() requests + var originalFetch = window.fetch; + + window.fetch = function(url, options) { + if (options && options.method && ["post", "patch", "put"].includes(options.method.toLowerCase()) && options.body) { + var reqId = 'nphp_' + (++_nphpReqId) + '_' + Date.now(); + + var bodyStr = options.body; + if (options.body instanceof FormData) { + // Convert FormData to URLSearchParams for PHP form parsing + var urlParams = new URLSearchParams(); + options.body.forEach(function(value, key) { + urlParams.append(key, value); + }); + bodyStr = urlParams.toString(); + } else if (typeof options.body === 'object' && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) { + bodyStr = JSON.stringify(options.body); + } + + AndroidPOST.logPostData(String(bodyStr), url, "", reqId); + + // Add request ID header to the actual fetch request + if (!options.headers) { + options.headers = {}; + } + if (options.headers instanceof Headers) { + options.headers.set('X-NativePHP-Req-Id', reqId); + } else { + options.headers['X-NativePHP-Req-Id'] = reqId; + } + } + return originalFetch.apply(this, arguments); + }; + + // Find CSRF token + function findAndSendCsrfToken() { + var tokenField = document.querySelector('input[name="_token"]'); + if (tokenField) { + AndroidPOST.storeCsrfToken(tokenField.value); + return; + } + + if (window.livewire && window.livewire.csrfToken) { + AndroidPOST.storeCsrfToken(window.livewire.csrfToken); + } + } + + findAndSendCsrfToken(); + + var observer = new MutationObserver(function() { + findAndSendCsrfToken(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + + return "POST+PATCH+PUT interception installed"; + })(); + """.trimIndent() + + view.evaluateJavascript(jsCode) { result -> + Log.d(TAG, "JavaScript injection result: $result") + } + } + + + private fun setupJavaScriptInterfaces() { + webView.addJavascriptInterface(JSBridge(phpBridge, TAG), "AndroidPOST") + webView.addJavascriptInterface(ttsBridge, "AndroidTTS") + Log.d(TAG, "[TTS] AndroidTTS bridge registered on WebView") + } + + fun shutdown() { + ttsBridge.shutdown() + } + + // Helper methods + fun isStaticAssetExtension(url: String): Boolean { + val staticExtensions = listOf( + ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".woff", + ".woff2", ".ttf", ".eot", ".ico", ".json", ".map" + ) + return staticExtensions.any { url.endsWith(it) || url.contains("$it?") } + } +} + +class JSBridge(private val phpBridge: PHPBridge, private val TAG: String) { + @JavascriptInterface + fun logPostData(data: String, url: String, headers: String, requestId: String) { + Log.d("$TAG-JS", "📦 POST data captured (fetch/XHR) for: $url reqId=$requestId (length=${data.length})") + + // Store by unique request ID — fetch/XHR requests carry the ID as a header + phpBridge.storePostData(requestId, data) + + // Try to extract CSRF token + LaravelSecurity.extractFromPostBody(data) + } + + @JavascriptInterface + fun logFormPostData(data: String, url: String) { + // Native form submissions can't carry custom headers, so store by URL + // shouldInterceptRequest will look up by URL in the fallback path + val path = android.net.Uri.parse(url).path ?: url + Log.d("$TAG-JS", "📦 POST data captured (form) for: $url path=$path (length=${data.length})") + + phpBridge.storePostData(url, data) + // Also store by path in case shouldInterceptRequest receives the full URL + if (path != url) { + phpBridge.storePostData(path, data) + } + + // Try to extract CSRF token + LaravelSecurity.extractFromPostBody(data) + } + + @JavascriptInterface + fun storeCsrfToken(token: String) { + Log.d("$TAG-CSRF", "🔑 JS provided token: $token") + LaravelSecurity.set(token) + } + + private fun extractCsrfToken(postData: String?) { + if (postData.isNullOrEmpty()) return + + try { + // Check if it's JSON + if (postData.startsWith("{")) { + val jsonObj = JSONObject(postData) + + // Look for _token field + if (jsonObj.has("_token")) { + val token = jsonObj.getString("_token") + Log.d("$TAG-CSRF", "🔑 Extracted token from POST data: $token") + LaravelSecurity.set(token) + } + } + // Check for form data format + else if (postData.contains("_token=")) { + val parts = postData.split("&") + for (part in parts) { + if (part.startsWith("_token=")) { + val token = part.substring("_token=".length) + Log.d("$TAG-CSRF", "🔑 Extracted token from form data: $token") + LaravelSecurity.set(token) + break + } + } + } + } catch (e: Exception) { + Log.e("$TAG-CSRF", "⚠️ Error extracting CSRF token: ${e.message}") + } + } +} diff --git a/vite.config.js b/vite.config.js index f35b4e7..f060356 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,14 +1,17 @@ -import { defineConfig } from 'vite'; +import {defineConfig} from 'vite'; import laravel from 'laravel-vite-plugin'; import tailwindcss from '@tailwindcss/vite'; +import {nativephpHotFile, nativephpMobile} from './vendor/nativephp/mobile/resources/js/vite-plugin.js'; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, + hotFile: nativephpHotFile(), }), tailwindcss(), + nativephpMobile(), ], server: { watch: { From 1349f2b9e6c914d599b40a32cfaa4386c320e2e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 08:04:54 +0000 Subject: [PATCH 6/9] chore: bump version to 1.0.2 [skip ci] --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index 588b684..ea3a8fb 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.0.1", - "version_code": 2 + "version": "1.0.2", + "version_code": 3 } From 93e5054910a2e903e93aaad77646f838ea421aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Buci=C4=87?= Date: Thu, 16 Apr 2026 11:02:47 +0200 Subject: [PATCH 7/9] Moving seeds to migration --- .github/workflows/bump-version.yml | 2 +- app/Models/Setting.php | 8 +-- app/Providers/AppServiceProvider.php | 13 +--- ...2026_04_16_081129_initial_program_seed.php | 61 +++++++++++++++++++ database/seeders/DatabaseSeeder.php | 43 ------------- 5 files changed, 64 insertions(+), 63 deletions(-) create mode 100644 database/migrations/2026_04_16_081129_initial_program_seed.php delete mode 100644 database/seeders/DatabaseSeeder.php diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index f20fc4a..10db264 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -15,7 +15,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Bump patch version and version_code run: | diff --git a/app/Models/Setting.php b/app/Models/Setting.php index f62fee6..b657969 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -30,12 +30,6 @@ class Setting extends Model /** Returns the single settings row, creating it with defaults on first run. */ public static function current(): self { - return self::first() ?? self::create([ - 'default_beep_lead_in' => BeepLeadIn::Three->value, - 'default_end_sound' => 'triple', - 'sound_mode' => 'beep', - 'volume' => 0.8, - 'keep_screen_on' => true, - ]); + return self::first(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bf60378..0d97006 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,9 +3,7 @@ namespace App\Providers; use App\Timer\TimerRunner; -use Database\Seeders\DatabaseSeeder; use Illuminate\Support\ServiceProvider; -use Throwable; class AppServiceProvider extends ServiceProvider { @@ -22,15 +20,6 @@ public function register(): void */ public function boot(): void { - // On the first installation (empty programs table) seed the demo HIIT program. - // The guard inside DatabaseSeeder::run() makes this idempotent. - // Wrapped in try/catch: during `artisan migrate` the program table - // does not yet exist when the service provider boots — swallow that - // gracefully and let the seeder succeed on the next boot. - try { - (new DatabaseSeeder)->run(); - } catch (Throwable $e) { - report($e); - } + } } diff --git a/database/migrations/2026_04_16_081129_initial_program_seed.php b/database/migrations/2026_04_16_081129_initial_program_seed.php new file mode 100644 index 0000000..0b6fed0 --- /dev/null +++ b/database/migrations/2026_04_16_081129_initial_program_seed.php @@ -0,0 +1,61 @@ +truncate(); + + DB::table('settings')->insert([ + 'default_beep_lead_in' => BeepLeadIn::Three->value, + 'default_end_sound' => 'triple', + 'sound_mode' => 'beep', + 'volume' => 0.8, + 'keep_screen_on' => true, + ]); + + $programId = Str::uuid7()->toString(); + Log::info(sprintf('Program ID: %s', $programId)); + + DB::table('programs')->insert([ + 'id' => $programId, + 'name' => 'HIIT', + 'beep_lead_in' => BeepLeadIn::Three->value, + 'end_sound' => 'chime', + ]); + + foreach ([ + ['label' => 'Warmup', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 5, 'color' => '#3b82f6'], + ['label' => 'Sprint', 'duration' => 8, 'repetitions' => 3, 'pause' => 4, 'cooldown' => 10, 'color' => '#ef4444'], + ['label' => 'Stretch', 'duration' => 8, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#22c55e'], + ] as $index => $phase) { + DB::table('phases')->insert( + array_merge( + $phase, + [ + 'program_id' => $programId, + 'sort_order' => $index, + ], + ), + ); + } + } + + + /** + * Reverse the migrations. + */ + public + function down(): void + { + // + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index 9ea6644..0000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,43 +0,0 @@ - 'HIIT', - 'beep_lead_in' => BeepLeadIn::Three->value, - 'end_sound' => 'chime', - ]); - - foreach ([ - ['label' => 'Warmup', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 5, 'color' => '#3b82f6'], - ['label' => 'Sprint', 'duration' => 8, 'repetitions' => 3, 'pause' => 4, 'cooldown' => 10, 'color' => '#ef4444'], - ['label' => 'Stretch','duration' => 8, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#22c55e'], - ] as $phase) { - $program->addPhase($phase); - } - } -} From 08979318a9fa0fe327458d2d1a7d8ed645533948 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 09:03:42 +0000 Subject: [PATCH 8/9] chore: bump version to 1.0.3 [skip ci] --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index ea3a8fb..44796bc 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.0.2", - "version_code": 3 + "version": "1.0.3", + "version_code": 4 } From bf53959bfcc18be0409cd857f71db1d6de2bd41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Buci=C4=87?= Date: Thu, 16 Apr 2026 20:51:34 +0200 Subject: [PATCH 9/9] fix: wire audioTTS module directly and include database in NativePHP 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.) --- .gitignore | 10 + app/Livewire/Settings.php | 19 +- app/Livewire/TimerScreen.php | 314 +++++++++--------- boost.json | 22 ++ composer.json | 8 +- composer.lock | 279 +++++++++++++++- config/nativephp.php | 1 + ...2026_04_16_081129_initial_program_seed.php | 22 +- resources/js/app.js | 49 +-- resources/js/audio.js | 50 +-- .../views/livewire/program-editor.blade.php | 7 +- 11 files changed, 549 insertions(+), 232 deletions(-) create mode 100644 boost.json diff --git a/.gitignore b/.gitignore index 59baa88..1d6655c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,13 @@ Thumbs.db # Credential files (keystores, private keys, etc.) /credentials/ + +/* AI */ +.agents +.mcp.json +CLAUDE.md +AGENTS.md +GEMINI.md +.claude +.junie +.gemini diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index d96a60f..8d04d4e 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -6,22 +6,24 @@ use App\Enum\BeepLeadIn; use App\Models\Setting; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rules\Enum; use Illuminate\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; -use Nbucic\AudioTts\AudioTTS; #[Layout('layouts.app')] #[Title('Settings — Interval Timer')] class Settings extends Component { public BeepLeadIn $defaultBeepLeadIn = BeepLeadIn::Three; + public string $defaultEndSound = 'triple'; + public string $soundMode = 'beep'; + public float $volume = 0.8; + public bool $keepScreenOn = true; public bool $saved = false; @@ -69,14 +71,13 @@ public function save(): void public function updateAndTest(string $soundMode): void { - Log::info('Updating settings and testing voice mode...'); $this->soundMode = $soundMode; - if ($soundMode === 'voice') { - \Nbucic\AudioTts\Facades\AudioTTS::speak('Where is Darth Vader now?', 1.0); -// $this->dispatch('playVoiceSound', reason: 'test'); - } else { - $this->dispatch('playBeepSound', sound: 'chime'); + if (app()->isLocal()) { + if ($soundMode === 'voice') { + $this->dispatch('play-TTS-Sound', text: '3, 2, 1, - GO'); + } else { + $this->dispatch('playBeepSound', sound: 'triple'); + } } - Log::info('Updating settings and testing voice mode... [DONE]'); } } diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php index e0b71ec..b6c927d 100644 --- a/app/Livewire/TimerScreen.php +++ b/app/Livewire/TimerScreen.php @@ -23,26 +23,37 @@ class TimerScreen extends Component { // ── Identifiers ─────────────────────────────────────────────────────── public ?string $programId = null; + public string $programName = ''; // ── Cursor snapshot (serializable scalars for Livewire) ────────────── public StateMachine $state = StateMachine::idle; + public int $remaining = 0; + public int $totalRemaining = 0; + public int $phaseIndex = 0; + public int $repIndex = 0; // ── Phase display ───────────────────────────────────────────────────── public string $phaseLabel = ''; + public string $phaseColor = '#3b82f6'; + public int $phaseReps = 1; + /** @var array[] Serialised Phase rows for the phase strip */ public array $phases = []; // ── Settings (for the JS audio layer) ──────────────────────────────── public string $soundMode = 'beep'; + public float $volume = 0.8; + public string $endSound = 'triple'; + public bool $keepScreenOn = true; // ── Ring countdown ──────────────────────────────────────────────────── @@ -55,160 +66,61 @@ class TimerScreen extends Component /** @var array[] serialised HistoryEntry rows + 'program_exists' bool */ public array $history = []; - public function mount(?string $id = null): void - { - $settings = Setting::current(); - $this->soundMode = $settings->sound_mode; - $this->volume = $settings->volume; - $this->keepScreenOn = $settings->keep_screen_on; - - if ($id) { - $this->loadProgram($id); - } else { - $this->loadHistory(); - } - } - - // ── Timer controls ──────────────────────────────────────────────────── - - public function loadProgram(string $id): void - { - $runner = app(TimerRunner::class); - $program = Program::with('phases')->findOrFail($id); - - $this->programId = $id; - $this->programName = $program->name; - $this->endSound = $program->end_sound; - $this->programTotalDuration = $program->totalDuration(); - $this->rehydrateRunner($runner); - - $this->syncCursor($runner->cursor(), $program); - - $this->dispatch('topbar-title', title: $program->name); - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); - } - public function discard(): void { app(TimerRunner::class)->discard(); - $this->state = StateMachine::idle; - $this->remaining = 0; + $this->state = StateMachine::idle; + $this->remaining = 0; $this->totalRemaining = 0; $this->dispatch('topbar-title', title: config('app.name')); } - public function pause(): void - { - $runner = app(TimerRunner::class); - $this->rehydrateRunner($runner); - $runner->pause(); - $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); - } - - public function resume(): void - { - $runner = app(TimerRunner::class); - $this->rehydrateRunner($runner); - $runner->resume(); - $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); - } - - public function restart(): void - { - $runner = app(TimerRunner::class); - $program = Program::with('phases')->findOrFail($this->programId); - $this->programTotalDuration = $program->totalDuration(); - $runner->load($program); - $this->syncCursor($runner->cursor(), $program); - $this->dispatch('topbar-title', title: config('app.name')); - } - - public function start(): void - { - $runner = app(TimerRunner::class); - $program = Program::with('phases')->findOrFail($this->programId); - - if ($program->phases->isEmpty()) { - return; - } - - $this->programTotalDuration = $program->totalDuration(); - $runner->load($program); - $runner->start(); - $this->syncCursor($runner->cursor(), $program); - - $this->dispatch('topbar-title', title: $this->programName); - } - - /** Called every second from JS setInterval via wire:poll equivalent. */ - public function tick(): void - { - $runner = app(TimerRunner::class); - $this->rehydrateRunner($runner); - - if (!$runner->cursor()->isActive()) { - return; - } - - $runner->tick(); - $cursor = $runner->cursor(); - $program = Program::with('phases')->findOrFail($this->programId); - - $this->syncCursor($cursor, $program); - - if ($cursor->isCompleted()) { - Log::info('Completed!'); - $this->dispatch('playEndSound', sound: $this->endSound); - $this->dispatch('topbar-title', title: config('app.name')); - } - } - - public function requestSettings(): void - { - $program = $this->programId ? Program::with('phases')->find($this->programId) : null; - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); - } - - public function render(): View - { - return view('livewire.timer-screen'); - } - - // ── Display helpers ─────────────────────────────────────────────────── + // ── Timer controls ──────────────────────────────────────────────────── public function formattedRemaining(): string { $s = $this->remaining; + return sprintf('%d:%02d', intdiv($s, 60), $s % 60); } public function formattedTotal(): string { $s = $this->totalRemaining; + return sprintf('%d:%02d', intdiv($s, 60), $s % 60); } - public function repLabel(): string + public function mount(?string $id = null): void { - if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) { - return ''; + $settings = Setting::current(); + $this->soundMode = $settings->sound_mode; + $this->volume = $settings->volume; + $this->keepScreenOn = $settings->keep_screen_on; + + if ($id) { + $this->loadProgram($id); + } else { + $this->loadHistory(); } - return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps); } - public function segmentLabel(): string + public function loadProgram(string $id): void { - return match ($this->state) { - StateMachine::prepare => 'Get Ready', - StateMachine::pause => 'Pause', - StateMachine::cooldown => 'Cooldown', - StateMachine::paused => 'Paused', - StateMachine::completed => 'Complete!', - default => $this->phaseLabel, - }; - } + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($id); - // ── Internals ───────────────────────────────────────────────────────── + $this->programId = $id; + $this->programName = $program->name; + $this->endSound = $program->end_sound; + $this->programTotalDuration = $program->totalDuration(); + $this->rehydrateRunner($runner); + + $this->syncCursor($runner->cursor(), $program); + + $this->dispatch('topbar-title', title: $program->name); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); + } private function rehydrateRunner(TimerRunner $runner): void { @@ -220,10 +132,10 @@ private function rehydrateRunner(TimerRunner $runner): void $runner->load($program); $cursor = new TimerCursor( - phaseIndex: $this->phaseIndex, - repIndex: $this->repIndex, - state: $this->state, - remaining: $this->remaining, + phaseIndex: $this->phaseIndex, + repIndex: $this->repIndex, + state: $this->state, + remaining: $this->remaining, totalRemaining: $this->totalRemaining, ); @@ -239,31 +151,31 @@ private function rehydrateRunner(TimerRunner $runner): void private function handleBeep(string $reason): void { $this->countdownLabel = match ($reason) { - 'prepare', 'countdown' => (string) $this->remaining, - 'rep_end' => 'Done', - 'pause_end' => 'Go', - 'cooldown_end' => 'Next', - default => '', + 'prepare', 'countdown' => (string)($this->remaining - 1), + 'rep_end' => 'Done', + 'pause_end' => 'Go', + 'cooldown_end' => 'Next', + default => '', }; $this->dispatch('playBeep', reason: $reason); } private function syncCursor(TimerCursor $cursor, Program $program): void { - $this->state = $cursor->state; - $this->remaining = $cursor->remaining; + $this->state = $cursor->state; + $this->remaining = $cursor->remaining; $this->totalRemaining = $cursor->totalRemaining; - $this->phaseIndex = $cursor->phaseIndex; - $this->repIndex = $cursor->repIndex; + $this->phaseIndex = $cursor->phaseIndex; + $this->repIndex = $cursor->repIndex; $this->phases = $program->phases ->map(fn(Phase $p) => [ - 'label' => $p->label, - 'duration' => $p->duration, + 'label' => $p->label, + 'duration' => $p->duration, 'repetitions' => $p->repetitions, - 'pause' => $p->pause, - 'cooldown' => $p->cooldown, - 'color' => $p->color, + 'pause' => $p->pause, + 'cooldown' => $p->cooldown, + 'color' => $p->color, ]) ->all(); @@ -271,7 +183,7 @@ private function syncCursor(TimerCursor $cursor, Program $program): void $phase = $program->phases[$cursor->phaseIndex]; $this->phaseLabel = $phase->label; $this->phaseColor = $phase->color; - $this->phaseReps = $phase->repetitions; + $this->phaseReps = $phase->repetitions; } } @@ -285,12 +197,114 @@ private function loadHistory(): void $this->history = $entries ->map(fn(HistoryEntry $e) => [ - 'program_id' => $e->program_id, - 'program_name' => $e->program_name, - 'completed_at' => $e->completed_at->toISOString(), + 'program_id' => $e->program_id, + 'program_name' => $e->program_name, + 'completed_at' => $e->completed_at->toISOString(), 'total_duration' => $e->total_duration, 'program_exists' => $e->program_id !== null && $existingIds->has($e->program_id), ]) ->all(); } + + public function pause(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + $runner->pause(); + $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); + } + + // ── Display helpers ─────────────────────────────────────────────────── + + public function render(): View + { + return view('livewire.timer-screen'); + } + + public function repLabel(): string + { + if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) { + return ''; + } + + return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps); + } + + public function requestSettings(): void + { + $program = $this->programId ? Program::with('phases')->find($this->programId) : null; + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); + } + + public function restart(): void + { + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($this->programId); + $this->programTotalDuration = $program->totalDuration(); + $runner->load($program); + $this->syncCursor($runner->cursor(), $program); + $this->dispatch('topbar-title', title: config('app.name')); + } + + // ── Internals ───────────────────────────────────────────────────────── + + public function resume(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + $runner->resume(); + $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); + } + + public function segmentLabel(): string + { + return match ($this->state) { + StateMachine::prepare => 'Get Ready', + StateMachine::pause => 'Pause', + StateMachine::cooldown => 'Cooldown', + StateMachine::paused => 'Paused', + StateMachine::completed => 'Complete!', + default => $this->phaseLabel, + }; + } + + public function start(): void + { + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($this->programId); + + if ($program->phases->isEmpty()) { + return; + } + + $this->programTotalDuration = $program->totalDuration(); + $runner->load($program); + $runner->start(); + $this->syncCursor($runner->cursor(), $program); + + $this->dispatch('topbar-title', title: $this->programName); + } + + /** Called every second from JS setInterval via wire:poll equivalent. */ + public function tick(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + + if (!$runner->cursor()->isActive()) { + return; + } + + $runner->tick(); + $cursor = $runner->cursor(); + $program = Program::with('phases')->findOrFail($this->programId); + + $this->syncCursor($cursor, $program); + + if ($cursor->isCompleted()) { + Log::info('Completed!'); + $this->dispatch('playEndSound', sound: $this->endSound); + $this->dispatch('topbar-title', title: config('app.name')); + } + } } diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..fd45a88 --- /dev/null +++ b/boost.json @@ -0,0 +1,22 @@ +{ + "agents": [ + "junie", + "claude_code", + "gemini" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": false, + "packages": [ + "nbucic/audio-tts", + "nativephp/mobile" + ], + "sail": false, + "skills": [ + "laravel-best-practices", + "livewire-development", + "pest-testing", + "tailwindcss-development", + "nativephp-mobile" + ] +} diff --git a/composer.json b/composer.json index fef06c5..4c1a44f 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^2.4", "laravel/pail": "^1.2.5", "laravel/pint": "^1.27", "mockery/mockery": "^1.6", @@ -29,8 +30,7 @@ }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Seeders\\": "database/seeders/" + "App\\": "app/" } }, "autoload-dev": { @@ -52,6 +52,10 @@ "Composer\\Config::disableProcessTimeout", "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], + "mobile": [ + "npm run build -- --mode=android", + "@php artisan native:run android" + ], "test": [ "@php artisan config:clear --ansi", "@php artisan test" diff --git a/composer.lock b/composer.lock index d8d846c..caab011 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bee163741fb2ea58eac28c450d281d22", + "content-hash": "28d31365cf997193a36f285d07a4b900", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7215,6 +7215,145 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "laravel/boost", + "version": "v2.4.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-04-10T15:59:10+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-15T08:30:42+00:00" + }, { "name": "laravel/pail", "version": "v1.2.6", @@ -7363,6 +7502,67 @@ }, "time": "2026-03-12T15:51:39+00:00" }, + { + "name": "laravel/roster", + "version": "v0.5.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2026-03-05T07:58:43+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -7508,7 +7708,7 @@ }, { "name": "nbucic/audio-tts", - "version": "dev-fix/tts-wakelock-caching", + "version": "dev-fix/timer-loading-and-keepscreenon", "dist": { "type": "path", "url": "/home/nikola/projects/private/interval-timer-nativephp/packages/nbucic/audio-tts", @@ -9840,6 +10040,81 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/yaml", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.7", diff --git a/config/nativephp.php b/config/nativephp.php index d12250d..8982051 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -275,6 +275,7 @@ 'resources', 'routes', 'config', + 'database', 'public', ], diff --git a/database/migrations/2026_04_16_081129_initial_program_seed.php b/database/migrations/2026_04_16_081129_initial_program_seed.php index 0b6fed0..25b9fde 100644 --- a/database/migrations/2026_04_16_081129_initial_program_seed.php +++ b/database/migrations/2026_04_16_081129_initial_program_seed.php @@ -6,7 +6,8 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -return new class extends Migration { +return new class extends Migration +{ /** * Run the migrations. */ @@ -33,10 +34,10 @@ public function up(): void ]); foreach ([ - ['label' => 'Warmup', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 5, 'color' => '#3b82f6'], - ['label' => 'Sprint', 'duration' => 8, 'repetitions' => 3, 'pause' => 4, 'cooldown' => 10, 'color' => '#ef4444'], - ['label' => 'Stretch', 'duration' => 8, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#22c55e'], - ] as $index => $phase) { + ['label' => 'Warmup', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 5, 'color' => '#3b82f6'], + ['label' => 'Sprint', 'duration' => 8, 'repetitions' => 3, 'pause' => 4, 'cooldown' => 10, 'color' => '#ef4444'], + ['label' => 'Stretch', 'duration' => 8, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#22c55e'], + ] as $index => $phase) { DB::table('phases')->insert( array_merge( $phase, @@ -47,14 +48,19 @@ public function up(): void ), ); } - } + DB::table('programs')->insert([ + 'id' => Str::uuid7()->toString(), + 'name' => 'Demo with no phases', + 'beep_lead_in' => 3, + 'end_sound' => 'triple', + ]); + } /** * Reverse the migrations. */ - public - function down(): void + public function down(): void { // } diff --git a/resources/js/app.js b/resources/js/app.js index 3ab345b..35c2c85 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,6 @@ import './bootstrap'; -import { initAudio } from './audio'; +import {initAudio} from './audio'; +import audioTTS from "../../packages/nbucic/audio-tts/resources/js/audioTTS.js"; // Boot Alpine after Livewire (Livewire v3 integrates automatically) document.addEventListener('alpine:init', () => { @@ -13,35 +14,35 @@ document.addEventListener('alpine:init', () => { _wakeLock: null, init() { - this.soundMode = this.$wire.soundMode; - this.volume = this.$wire.volume; + this.soundMode = this.$wire.soundMode; + this.volume = this.$wire.volume; this.keepScreenOn = this.$wire.keepScreenOn; - this.program = this.$wire.program; - this.audio = initAudio(this.volume); + this.program = this.$wire.program; + this.audio = initAudio(this.volume); console.log('[TTS] timerAudio init: soundMode=' + this.soundMode + ', keepScreenOn=' + this.keepScreenOn - + ', AndroidTTS=' + !!(window.AndroidTTS && typeof window.AndroidTTS.speak === 'function')); + + ', AndroidTTS=' + (typeof audioTTS.speak === 'function')); // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active - this.$watch('$wire.state', state => { + this.$watch('$wire.state', async state => { console.log('State changed:', state); clearInterval(this.interval); if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(state)) { this.interval = setInterval(() => this.$wire.tick(), 1000); - this._acquireWakeLock(); + await this._acquireWakeLock(); } else { - this._releaseWakeLock(); + await this._releaseWakeLock(); } }); - this.$wire.on('playBeep', ({reason}) => { - console.log('playBeep', reason); + this.$wire.on('playBeep', async ({reason}) => { + console.log(`Beep, reason: ${reason}, mode: ${this.soundMode}`); if (this.soundMode === 'voice') { const text = this.voiceText(reason); console.log('[TTS] playBeep: reason=' + reason + ', voiceText="' + text + '"'); - this.audio.speak(text); + await audioTTS.speak(text); } else if (reason === 'prepare') { this.audio.prepareBeep(); } else { @@ -66,7 +67,7 @@ document.addEventListener('alpine:init', () => { if (soundMode !== undefined) this.soundMode = soundMode; if (volume !== undefined) { this.volume = volume; - this.audio = initAudio(volume); + this.audio = initAudio(volume); } if (keepScreenOn !== undefined) this.keepScreenOn = keepScreenOn; }); @@ -74,10 +75,10 @@ document.addEventListener('alpine:init', () => { voiceText(reason) { const map = { - prepare: this.$wire?.countdownLabel ?? '', - countdown: this.$wire?.countdownLabel ?? 'Get ready', - rep_end: 'Done', - pause_end: 'Go', + prepare: this.$wire?.countdownLabel ?? '', + countdown: this.$wire?.countdownLabel ?? 'Get ready', + rep_end: 'Done', + pause_end: 'Go', cooldown_end: 'Next', }; return map[reason] ?? 'Beep'; @@ -90,7 +91,7 @@ document.addEventListener('alpine:init', () => { try { this._wakeLock = await navigator.wakeLock.request('screen'); console.log('Wake lock acquired'); - // Re-acquire automatically if the OS releases it (e.g. tab hidden then shown) + // Re-acquire automatically if the OS releases it (e.g., tab hidden then shown) this._wakeLock.addEventListener('release', () => { this._wakeLock = null; }); @@ -115,20 +116,24 @@ document.addEventListener('alpine:init', () => { volume: 0.8, init() { - console.log('settings sounds'); this.soundMode = this.$wire.soundMode; this.volume = this.$wire.volume; - this.audio = initAudio(1); + this.audio = initAudio(1); this.$wire.on('playBeepSound', ({sound}) => { - console.log('DEMO'); - debugger; + console.log('Playing beep sound'); if (sound === 'triple') { this.audio.tripleBeep(); } else { this.audio.chime(); } }); + + this.$wire.on('play-TTS-Sound', ({text}) => { + console.log('Playing ho ho ho'); + audioTTS.speak(text).then(() => { + }); + }) } })) }); diff --git a/resources/js/audio.js b/resources/js/audio.js index 683d54e..67ef860 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -1,16 +1,5 @@ -// noinspection JSUnresolvedReference - -import audioTTS from "../../packages/nbucic/audio-tts/resources/js/audioTTS.js"; - /** * Audio engine for the interval timer. - * - * soundMode = 'beep' -> Web Audio API (synthesized, no network) - * soundMode = 'voice' -> Android TTS via window.AndroidTTS bridge (TTSBridge.kt) - * Falls back to Web Speech API in browser dev. - * - * All methods are no-ops until the user has interacted with the page, - * satisfying the browser AudioContext autoplay policy. */ export function initAudio(volume = 0.8) { volume = Math.max(0, Math.min(1, volume)); @@ -18,7 +7,7 @@ export function initAudio(volume = 0.8) { function getCtx() { if (!ctx) { - ctx = new (window.AudioContext || window.webkitAudioContext)(); + ctx = new (window.AudioContext)(); } if (ctx.state === 'suspended') { ctx.resume(); @@ -28,13 +17,13 @@ export function initAudio(volume = 0.8) { /** Play a single short beep at the given frequency and duration. */ function tone(freq = 880, durationMs = 120, delayMs = 0) { - const c = getCtx(); - const osc = c.createOscillator(); - const gain = c.createGain(); - const start = c.currentTime + delayMs / 1000; - const end = start + durationMs / 1000; + const c = getCtx(); + const osc = c.createOscillator(); + const gain = c.createGain(); + const start = c.currentTime + delayMs / 1000; + const end = start + durationMs / 1000; - osc.type = 'sine'; + osc.type = 'sine'; osc.frequency.setValueAtTime(freq, start); gain.gain.setValueAtTime(volume * 0.6, start); gain.gain.exponentialRampToValueAtTime(0.001, end); @@ -46,7 +35,9 @@ export function initAudio(volume = 0.8) { } /** Single countdown beep (800 Hz, 100 ms). */ - function beep() { tone(800, 100); } + function beep() { + tone(800, 100); + } /** Prepare-phase beep -- three rapid beeps at the same tone (800 Hz, 100 ms x 3). */ function prepareBeep() { @@ -56,7 +47,9 @@ export function initAudio(volume = 0.8) { } /** Gentle single beep on user pause (600 Hz, 80 ms). */ - function pauseBeep() { tone(600, 80); } + function pauseBeep() { + tone(600, 80); + } /** * End sound: triple beep (three 880 Hz tones 150 ms apart). @@ -74,20 +67,8 @@ export function initAudio(volume = 0.8) { */ function chime() { tone(1046.5, 300, 0); // C6 - tone(880, 300, 120); // A5 - tone(698.5, 400, 240); // F5 - } - - /** - * Speak text via the Android TTS bridge (window.AndroidTTS, registered by - * TTSBridge.kt) when running inside the NativePHP WebView. - * - * Falls back to Web Speech API for browser-based development. - * Logs every decision point so the full chain is visible in both - * Android Logcat (via WebChromeClient console forwarding) and DevTools. - */ - function speak(text) { - return audioTTS.speak(text); + tone(880, 300, 120); // A5 + tone(698.5, 400, 240); // F5 } return { @@ -96,6 +77,5 @@ export function initAudio(volume = 0.8) { pauseBeep, tripleBeep, chime, - speak, }; } diff --git a/resources/views/livewire/program-editor.blade.php b/resources/views/livewire/program-editor.blade.php index 9b1cd4f..cd69113 100644 --- a/resources/views/livewire/program-editor.blade.php +++ b/resources/views/livewire/program-editor.blade.php @@ -251,19 +251,18 @@ class="w-full bg-gray-800 text-white rounded-xl px-4 py-3 {{ $this->editingIsLastPhase() ? 'text-gray-600' : 'text-gray-400' }}"> Cooldown (sec) -

+

@if($this->editingIsLastPhase()) - not counted — add another phase + The cooldown period for the last phase will not be counted in total duration @else After final rep @endif

editingIsLastPhase()) disabled @endif class="w-full rounded-xl px-4 py-3 border text-center text-lg font-bold focus:outline-none transition-colors {{ $this->editingIsLastPhase() - ? 'bg-gray-800/40 text-gray-600 border-white/5 cursor-not-allowed' + ? 'bg-gray-800/40 text-gray-600 border-white/5' : 'bg-gray-800 text-white border-white/10 focus:border-blue-500' }}">