From 92e955915ea00fa90e958eea55138fed52f2042d Mon Sep 17 00:00:00 2001 From: David Frassi Date: Thu, 4 Jun 2026 19:09:46 +0200 Subject: [PATCH] feat: allinea lo swipe e lo scroll con l'avanzamento karaoke, aggiungi frecce di navigazione canti nel titolo --- deploy_www.sh | 65 +--- public/.htaccess | 9 + src/app/app.component.ts | 131 ++++--- src/app/pages/player/player.page.html | 30 +- src/app/pages/player/player.page.scss | 56 ++- src/app/pages/player/player.page.ts | 428 ++++++++++++++++++++-- src/app/pages/settings/settings.page.html | 8 + src/app/pages/settings/settings.page.ts | 24 +- src/app/services/audio-engine.service.ts | 190 +++++++++- src/app/services/settings.service.ts | 20 + src/app/version.ts | 2 +- src/index.html | 8 + 12 files changed, 820 insertions(+), 151 deletions(-) diff --git a/deploy_www.sh b/deploy_www.sh index 86be67b..b7a66ed 100755 --- a/deploy_www.sh +++ b/deploy_www.sh @@ -52,67 +52,4 @@ if [ ! -d "www" ]; then fi # --- Upload via FTP --- -echo "🚀 2/2 Caricamento parallelo ($THREADS connessioni) su $FTP_HOST nella ROOT..." -cd www - -TOTAL_FILES=$(find . -type f | wc -l | xargs) -PROGRESS_LOG=$(mktemp) -ERROR_LOG=$(mktemp) - -show_progress() { - local current=0 - while [ "$current" -lt "$TOTAL_FILES" ]; do - current=$(wc -l < "$PROGRESS_LOG" | xargs) - local percent=$((current * 100 / TOTAL_FILES)) - local bar_size=20 - local num_hash=$((percent * bar_size / 100)) - local bar=$(printf "%${num_hash}s" | tr ' ' '#' 2>/dev/null) - local spaces=$(printf "%$((bar_size - num_hash))s" | tr ' ' '-') - printf "\r[%-20s] %d%% (%d/%d) caricati..." "$bar$spaces" "$percent" "$current" "$TOTAL_FILES" - sleep 0.2 - done -} - -show_progress & -BAR_PID=$! - -# Lancio dei job paralleli -find . -type f | while read -r file; do - REMOTE_FILE_PATH=${file#./} - - # Eseguiamo curl e segniamo SEMPRE il progresso (anche se fallisce) - ( - # Usiamo --retry per gestire errori temporanei di connessione - if ! curl -s --retry 3 --retry-delay 1 --connect-timeout 10 -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$file" "ftp://$FTP_HOST/$REMOTE_FILE_PATH"; then - echo "$REMOTE_FILE_PATH" >> "$ERROR_LOG" - fi - echo 1 >> "$PROGRESS_LOG" - ) & - - # Gestione THREADS - while [ $(jobs -r | wc -l) -ge "$THREADS" ]; do - sleep 0.05 - done -done - -# Attendi fine caricamenti -wait - -# Stop barra -sleep 0.5 -kill $BAR_PID 2>/dev/null -echo "" - -# Controllo errori -ERRORS=$(wc -l < "$ERROR_LOG" | xargs) -if [ "$ERRORS" -gt 0 ]; then - echo "⚠️ Deploy completato con $ERRORS errori." - echo "I seguenti file non sono stati caricati:" - cat "$ERROR_LOG" -else - echo "✅ Deploy completato con successo nella ROOT senza errori!" -fi - -# Cleanup -rm "$PROGRESS_LOG" "$ERROR_LOG" -printf "L'app è disponibile su https://www.canticristiani.it/\n" +python3 scratch/deploy_ftp.py diff --git a/public/.htaccess b/public/.htaccess index ba15edc..2143cf2 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -8,3 +8,12 @@ # Altrimenti reindirizza tutto a index.html (gestito da Angular) RewriteRule ^ index.html [L] + + + # Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker + + Header set Cache-Control "no-cache, no-store, must-revalidate" + Header set Pragma "no-cache" + Header set Expires 0 + + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4f22289..9b9d97b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,68 +19,117 @@ export class AppComponent { this.setupUpdates(); } + private async forceBypassCacheAndCheck(): Promise { + try { + // 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.update(); + console.log('[PWA-Update] Native Service Worker updated'); + } + } + // 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali + await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' }); + await fetch('/ngsw.json', { cache: 'reload' }); + console.log('[PWA-Update] Caches successfully busted for ngsw.json'); + } catch (e) { + console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e); + } + return await this.swUpdate.checkForUpdate(); + } + private setupUpdates() { if (this.swUpdate.isEnabled) { - // Wait for the application to stabilize before running update checks or starting intervals + const launchTime = Date.now(); + + // Ricarica automaticamente se il Service Worker controller cambia per garantire la freschezza immediata + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('controllerchange', () => { + console.log('[PWA-Update] Controller changed. Reloading page...'); + window.location.reload(); + }); + } + + // 1. Sottoscrizione all'evento di versione scaricata/pronta + this.swUpdate.versionUpdates + .pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY')) + .subscribe(async () => { + const timeSinceLaunch = Date.now() - launchTime; + console.log(`[PWA-Update] New version ready! Time since launch: ${timeSinceLaunch}ms`); + + if (timeSinceLaunch < 8000) { + // Se l'applicazione è appena stata aperta (< 8s), la aggiorniamo ed eseguiamo il reload immediato e silenzioso + console.log('[PWA-Update] Auto-activating update on startup...'); + try { + await this.swUpdate.activateUpdate(); + console.log('[PWA-Update] Update activated successfully, reloading page...'); + window.location.reload(); + } catch (err) { + console.error('[PWA-Update] Auto-activation failed on startup:', err); + // Fallback: ricarica comunque per provare a forzare l'attivazione + window.location.reload(); + } + } else { + // Altrimenti mostriamo il prompt interattivo per evitare interruzioni improvvise + console.log('[PWA-Update] Showing toast prompt for active user...'); + const toast = await this.toastCtrl.create({ + message: 'Nuova versione dell\'applicazione disponibile!', + position: 'bottom', + color: 'secondary', + buttons: [ + { + text: 'Aggiorna', + role: 'cancel', + handler: () => { + console.log('[PWA-Update] Activating update and reloading...'); + this.swUpdate.activateUpdate().then(() => { + window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); + }); + } + } + ] + }); + await toast.present(); + } + }); + + // 2. Controllo IMMEDIATO all'avvio dell'applicazione (senza attendere lo stato isStable) + console.log('[PWA-Update] Startup: Checking for PWA updates immediately...'); + this.forceBypassCacheAndCheck().catch(err => { + console.warn('[PWA-Update] Startup update check failed:', err); + }); + + // 3. Attiva i controlli periodici in background solo DOPO la stabilizzazione dell'app this.appRef.isStable.pipe( filter(stable => stable), first() ).subscribe(() => { - console.log('[PWA-Update] App is stable. Initializing update checks...'); + console.log('[PWA-Update] App is stable. Initializing background updates...'); - // 1. Check for updates immediately - this.swUpdate.checkForUpdate().catch(err => { - console.warn('[PWA-Update] Failed immediate startup update check:', err); - }); - - // 2. Periodic check in background every 30 seconds - const every30Seconds$ = interval(30 * 1000); - every30Seconds$.subscribe(async () => { - console.log('[PWA-Update] Periodic check for updates (every 30s)...'); + // Controllo periodico ogni 60 secondi + const every60Seconds$ = interval(60 * 1000); + every60Seconds$.subscribe(async () => { + console.log('[PWA-Update] Periodic check for updates (every 60s)...'); try { - await this.swUpdate.checkForUpdate(); + await this.forceBypassCacheAndCheck(); } catch (err) { - console.warn('[PWA-Update] Failed periodic update check:', err); + console.warn('[PWA-Update] Periodic update check failed:', err); } }); }); - // 3. Check for updates when the app is resumed/focused + // 4. Controllo quando l'utente torna sulla scheda o ripristina l'app (Visibility Change) fromEvent(document, 'visibilitychange') .pipe(filter(() => document.visibilityState === 'visible')) .subscribe(async () => { console.log('[PWA-Update] App resumed, checking for PWA updates...'); try { - await this.swUpdate.checkForUpdate(); + await this.forceBypassCacheAndCheck(); } catch (err) { - console.warn('[PWA-Update] Failed visible resume update check:', err); + console.warn('[PWA-Update] Resume update check failed:', err); } }); - - // 4. Activate update and reload when a new version is ready (Show interactive toast) - this.swUpdate.versionUpdates - .pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY')) - .subscribe(async () => { - console.log('[PWA-Update] New version ready! Showing toast prompt...'); - const toast = await this.toastCtrl.create({ - message: 'Nuova versione dell\'applicazione disponibile!', - position: 'bottom', - color: 'secondary', - buttons: [ - { - text: 'Aggiorna', - role: 'cancel', - handler: () => { - console.log('[PWA-Update] Activating update and reloading...'); - this.swUpdate.activateUpdate().then(() => { - window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); - }); - } - } - ] - }); - await toast.present(); - }); } } } diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index 4717d44..e5f23a0 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -8,9 +8,17 @@ {{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }} - + {{ getCommunitySongNumber(canto()) }} {{ canto()?.titolo || 'Player' }} + + + + + + + + @@ -37,7 +45,8 @@ [class.full-screen-container]="settingsService.fullscreenMode()" (touchstart)="onTouchStart($event)" (touchmove)="onTouchMove($event)" - (touchend)="onTouchEnd()"> + (touchend)="onTouchEnd()" + (wheel)="onWheel($event)"> @@ -88,6 +97,8 @@ + +
@@ -126,9 +137,18 @@ - -
-
+ +
+
+
+
+ +
+
+ + Parole Rilevate: {{ wordsSpokenInCurrentPage }} / {{ getPageTargetWordCount() }} +
+
diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index 62dcfe2..14ea070 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -258,9 +258,7 @@ // Transcript area .transcript-area { - height: 4px; width: 100%; - background: rgba(0,0,0,0.2); .energy-bar { height: 100%; @@ -270,6 +268,12 @@ } } +@keyframes pulse { + 0% { transform: scale(0.9); opacity: 0.6; } + 50% { transform: scale(1.15); opacity: 1; } + 100% { transform: scale(0.9); opacity: 0.6; } +} + // Fullscreen and Orientation @media (orientation: landscape) { // Always hide footer in landscape as we have side controls @@ -560,3 +564,51 @@ ion-content.full-screen-content { padding-inline-start: 8px; } } + +.autoscroll-sync-status { + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(212, 136, 0, 0.18) !important; + border: 1px solid rgba(253, 203, 110, 0.5); + padding: 6px 14px; + border-radius: 16px; + z-index: 999; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + animation: slideDownSync 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + pointer-events: none; + + span { + font-size: 0.8rem; + font-weight: 700; + color: #fdcb6e; + letter-spacing: 0.3px; + } + + .spin-animation { + animation: spinSync 2s linear infinite; + font-size: 1.1rem; + } +} + +@keyframes spinSync { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes slideDownSync { + from { + opacity: 0; + transform: translate(-50%, -20px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index 9f7c1c2..5590cae 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -83,6 +83,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public youtubePlayerService = inject(YoutubePlayerService); private channel = new BroadcastChannel('karaoke_sync'); + // Rimossi stati di freeze fragili per garantire fluidità e stabilità 100% dell'avanzamento + private readonly MIN_FONT = 0.6; private readonly MAX_FONT = 5.0; private readonly FONT_STEP = 0.15; @@ -96,7 +98,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { private lastAdvanceTimestamp: number = 0; // For safety cooldown private readonly ADVANCE_COOLDOWN = 1500; // 1.5 seconds min - private wordsSpokenInCurrentLine: number = 0; + public wordsSpokenInCurrentPage: number = 0; + public lastScrollBlock = signal<'start' | 'center'>('start'); private lastProcessedTranscript: string = ''; private initialStartTime: number = 0; @@ -132,11 +135,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.showChords.set(this.settingsService.showChordsDefault()); }, { allowSignalWrites: true }); - // Automatic advancement logic based on line detection + // Avanzamento guidato dai picchi acustici con conteggio parole della riga in corso effect(() => { const count = this.audioEngine.linesDetected(); - if (count > 0) { - this.next(); + if (count > 0 && this.audioEngine.isListening()) { + this.handleAcousticPeak(); + } + }); + + // Avanzamento in tempo reale tramite riconoscimento vocale di prossimità (driver primario veloce) + effect(() => { + const transcript = this.audioEngine.backgroundTranscript(); + const isSpeechActive = this.audioEngine.speechRecognitionActive(); + if (transcript && this.audioEngine.isListening() && isSpeechActive) { + this.checkWordAdvancement(); } }); @@ -147,7 +159,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { setTimeout(() => { const activeElem = document.querySelector('.lyric-line.active'); if (activeElem) { - activeElem.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Usa l'allineamento calcolato dinamico (start per manuale a pagine, center per automatico e standard) + // Usiamo behavior: 'auto' poiché il container CSS ha già 'scroll-behavior: smooth', evitando blocchi/conflitti nativi Chromium + const scrollBlock = this.lastScrollBlock(); + activeElem.scrollIntoView({ behavior: 'auto', block: scrollBlock }); } }, 100); }); @@ -241,26 +256,45 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } ngAfterViewInit() { - const gesture = this.gestureCtrl.create({ + const gestureX = this.gestureCtrl.create({ el: this.el.nativeElement, direction: 'x', - gestureName: 'swipe-song', + gestureName: 'swipe-song-x', canStart: (ev) => { - // Prevent swipe when touching the bottom toolbar or other interactive elements const target = ev.event.target as HTMLElement; - return !target.closest('ion-footer'); + return !target.closest('ion-footer') && !target.closest('ion-header'); }, onEnd: (ev) => { if (Math.abs(ev.deltaX) > 60) { if (ev.deltaX > 0) { - this.prevSong(); + this.prev(); } else { - this.nextSong(); + this.next(); } } } }); - gesture.enable(); + gestureX.enable(); + + const gestureY = this.gestureCtrl.create({ + el: this.el.nativeElement, + direction: 'y', + gestureName: 'swipe-song-y', + canStart: (ev) => { + const target = ev.event.target as HTMLElement; + return !target.closest('ion-footer') && !target.closest('ion-header'); + }, + onEnd: (ev) => { + if (Math.abs(ev.deltaY) > 50) { + if (ev.deltaY > 0) { + this.prev(); + } else { + this.next(); + } + } + } + }); + gestureY.enable(); } @@ -327,6 +361,25 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.initialPinchDistance = null; } + private lastWheelTime = 0; + private readonly WHEEL_COOLDOWN = 400; // ms to avoid too rapid advancement + + onWheel(event: WheelEvent) { + event.preventDefault(); + const now = Date.now(); + if (now - this.lastWheelTime < this.WHEEL_COOLDOWN) { + return; + } + if (Math.abs(event.deltaY) > 5) { + if (event.deltaY > 0) { + this.next(); + } else { + this.prev(); + } + this.lastWheelTime = now; + } + } + private getDistance(t1: Touch, t2: Touch): number { return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2)); } @@ -442,25 +495,356 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.audioEngine.resetWordCount(); this.youtubePlayerService.seekTo(0); this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 }); + this.wordsSpokenInCurrentPage = 0; + this.lastScrollBlock.set('center'); } - next() { - this.lastAdvanceTimestamp = Date.now(); - this.audioEngine.resetWordCount(); + public calculatePageStepSize(): number { + try { + const container = document.querySelector('.lyrics-container') as HTMLElement; + if (!container) return 3; // Ritorno generico se il contenitore non è pronto - const totalLines = this.getTotalLines(); - if (this.currentLineIndex() < totalLines - 1) { - this.currentLineIndex.set(this.currentLineIndex() + 1); - this.channel.postMessage({ type: 'SYNC_INDEX', index: this.currentLineIndex() }); + const containerHeight = container.clientHeight; + + // Calcoliamo la reale altezza visibile sottraendo l'eventuale footer in sovraimpressione + let visibleHeight = containerHeight; + const footer = document.querySelector('ion-footer') as HTMLElement; + if (footer && footer.offsetHeight > 0) { + const computedStyle = window.getComputedStyle(footer); + if (computedStyle.display !== 'none') { + visibleHeight -= footer.offsetHeight; + } + } + + const lineElems = document.querySelectorAll('.lyric-line'); + + if (lineElems.length === 0) return 3; + + let totalHeight = 0; + lineElems.forEach((el: any) => { + totalHeight += el.clientHeight; + }); + const avgLineHeight = totalHeight / lineElems.length; + + if (avgLineHeight <= 0) return 3; + + // Quante righe entrano effettivamente nella vista REALE non oscurata dello schermo + const linesPerPage = Math.floor(visibleHeight / avgLineHeight); + + // Passo di scorrimento: una pagina intera pulita (zero sovrapposizione) per far sparire il testo precedente + const pageStep = Math.max(1, linesPerPage); + + console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', { + containerHeight, + visibleHeight, + avgLineHeight, + linesPerPage, + pageStep + }); + + return pageStep; + } catch (e) { + console.warn('[PageScroll] Impossibile calcolare dinamicamente lo step di pagina:', e); + return 3; // Fallback di emergenza } } + private getLineWords(lineIndex: number): string[] { + const allLines = this.getAllLines(); + const lineText = allLines[lineIndex]?.text || ''; + return lineText + .toLowerCase() + .split(/\s+/) + .map((w: string) => w.replace(/[^a-zA-Z0-9àèìòùáéíóú]/g, '')) + .filter((w: string) => w.length >= 3); + } + + public getPageWords(startIndex: number, pageSize: number): string[] { + const allWords: string[] = []; + const totalLines = this.getTotalLines(); + const end = Math.min(totalLines, startIndex + pageSize); + for (let i = startIndex; i < end; i++) { + allWords.push(...this.getLineWords(i)); + } + return allWords; + } + + public getVisibleLineIndices(): number[] { + try { + const container = document.querySelector('.lyrics-container') as HTMLElement; + if (!container) return []; + + const containerRect = container.getBoundingClientRect(); + const visibleTop = containerRect.top; + let visibleBottom = containerRect.bottom; + + // Sottrai l'altezza dell'eventuale footer sovrapposto ad alto z-index + const footer = document.querySelector('ion-footer') as HTMLElement; + if (footer) { + const footerRect = footer.getBoundingClientRect(); + if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') { + visibleBottom = Math.min(visibleBottom, footerRect.top); + } + } + + const lineElems = document.querySelectorAll('.lyric-line'); + const visibleIndices: number[] = []; + + for (let i = 0; i < lineElems.length; i++) { + const el = lineElems[i] as HTMLElement; + const rect = el.getBoundingClientRect(); + + // Consideriamo la riga visibile se la sua metà verticale è all'interno dei limiti visibili reali + const lineMiddle = (rect.top + rect.bottom) / 2; + if (lineMiddle >= visibleTop && lineMiddle <= visibleBottom) { + visibleIndices.push(i); + } + } + return visibleIndices; + } catch (e) { + console.warn('[PageScroll] Errore nel recupero degli indici delle righe visibili:', e); + return []; + } + } + + public getPageTargetWordCount(): number { + const visibleIndices = this.getVisibleLineIndices(); + let totalRawWords = 0; + const currentIdx = this.currentLineIndex(); + + if (visibleIndices.length > 0) { + if (currentIdx === 0) { + // Sulla prima pagina, contiamo solo la prima metà delle righe visibili + // per far avanzare l'applicazione non appena si arriva a metà della prima schermata + const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2)); + for (let i = 0; i < halfLength; i++) { + const idx = visibleIndices[i]; + totalRawWords += this.getLineRawWordCount(idx); + } + } else { + // Dalla seconda pagina in poi, contiamo solo le righe attive e future (escludendo il testo già cantato in alto) + for (const idx of visibleIndices) { + if (idx >= currentIdx) { + totalRawWords += this.getLineRawWordCount(idx); + } + } + } + } else { + // Fallback: calcolo teorico basato sulla pagina corrente + const pageSize = this.calculatePageStepSize(); + const totalLines = this.getTotalLines(); + + let end: number; + if (currentIdx === 0) { + // Fallback prima pagina: metà delle righe della pagina teorica + const halfPageSize = Math.max(1, Math.ceil(pageSize / 2)); + end = Math.min(totalLines, currentIdx + halfPageSize); + } else { + end = Math.min(totalLines, currentIdx + pageSize); + } + + for (let i = currentIdx; i < end; i++) { + totalRawWords += this.getLineRawWordCount(i); + } + } + + // Soglia al 70% delle parole contate nella schermata visibile + return Math.max(1, Math.ceil(totalRawWords * 0.70)); + } + + private checkWordAdvancement() { + const now = Date.now(); + + // Cooldown per prevenire avanzamenti multipli troppo veloci + if (now - this.lastAdvanceTimestamp < this.ADVANCE_COOLDOWN) { + return; + } + + const currentIdx = this.currentLineIndex(); + let nextPageIdx: number; + const totalLines = this.getTotalLines(); + + if (currentIdx === 0) { + // Sulla prima pagina, la "pagina successiva" è in realtà la seconda metà della prima schermata + const visibleIndices = this.getVisibleLineIndices(); + if (visibleIndices.length > 0) { + const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2)); + nextPageIdx = visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength; + } else { + const pageSize = this.calculatePageStepSize(); + const halfPageSize = Math.max(1, Math.ceil(pageSize / 2)); + nextPageIdx = currentIdx + halfPageSize; + } + } else { + nextPageIdx = this.calculateNextPageStartIndex(); + } + + if (nextPageIdx >= totalLines) return; + + const pageSize = this.calculatePageStepSize(); + + // Raccoglie le parole del blocco successivo (metà pagina se siamo all'inizio, altrimenti intera pagina) + const nextPageWords = this.getPageWords(nextPageIdx, currentIdx === 0 ? Math.max(1, Math.ceil(pageSize / 2)) : pageSize); + if (nextPageWords.length === 0) return; + + // Parole rilevate di recente (con tolleranza di ritardo della Speech API) + const recentWords = this.audioEngine.getRecentWordsSince(this.lastAdvanceTimestamp); + + // Controlla se l'utente ha cantato parole della pagina successiva -> Avanzamento istantaneo immediato + const matchNext = nextPageWords.some(word => recentWords.includes(word)); + if (matchNext) { + console.log(`[SmartKaraoke] Parola chiave del blocco successivo rilevata! Avanzamento immediato a: ${nextPageIdx}`); + this.lastScrollBlock.set('center'); + this.currentLineIndex.set(nextPageIdx); + this.channel.postMessage({ type: 'SYNC_INDEX', index: nextPageIdx }); + this.lastAdvanceTimestamp = now; + this.audioEngine.resetWordCount(); + this.wordsSpokenInCurrentPage = 0; + } + } + + public getLineRawWordCount(lineIndex: number): number { + const allLines = this.getAllLines(); + const lineText = allLines[lineIndex]?.text || ''; + const words = lineText.trim().split(/\s+/).filter((w: string) => w.length > 0); + return words.length; + } + + public getLineTargetWordCount(lineIndex: number): number { + const rawCount = this.getLineRawWordCount(lineIndex); + // Soglia proporzionale tollerante (65% del conteggio parole effettivo) per compensare legature melodiche + return Math.max(1, Math.ceil(rawCount * 0.65)); + } + + private handleAcousticPeak() { + const targetWordCount = this.getPageTargetWordCount(); + + this.wordsSpokenInCurrentPage++; + console.log(`[PeakAdvance] Peak detected. Spoken words on page: ${this.wordsSpokenInCurrentPage}/${targetWordCount}`); + + if (this.wordsSpokenInCurrentPage >= targetWordCount) { + console.log(`[PeakAdvance] Target page word count reached (${targetWordCount}). Advancing page.`); + this.next(true); + } + } + + private calculateNextPageStartIndex(): number { + try { + const container = document.querySelector('.lyrics-container') as HTMLElement; + if (!container) return this.currentLineIndex() + 1; + + const containerRect = container.getBoundingClientRect(); + let visibleBottom = containerRect.bottom; + + // Trova la posizione del footer o di qualunque elemento sovrapposto in fondo + const footer = document.querySelector('ion-footer') as HTMLElement; + if (footer) { + const footerRect = footer.getBoundingClientRect(); + if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') { + visibleBottom = Math.min(visibleBottom, footerRect.top); + } + } + + // Sottrai un piccolo margine di tolleranza di 8px + const visibleLimitY = visibleBottom - 8; + const lineElems = document.querySelectorAll('.lyric-line'); + + const totalLines = this.getTotalLines(); + const currentIdx = this.currentLineIndex(); + + // Scansiona le righe successive a quella attiva e trova la prima riga che non era COMPLETAMENTE visibile + let foundNextIdx = -1; + for (let i = currentIdx + 1; i < lineElems.length; i++) { + const el = lineElems[i] as HTMLElement; + const rect = el.getBoundingClientRect(); + + // Se la parte inferiore della riga ricade sotto l'area di visualizzazione utile (coperta da footer/log) + if (rect.bottom > visibleLimitY) { + foundNextIdx = i; + break; + } + } + + if (foundNextIdx !== -1) { + console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx); + return foundNextIdx; + } + + // Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard + const step = this.calculatePageStepSize(); + return Math.min(totalLines - 1, currentIdx + step); + } catch (e) { + console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e); + return this.currentLineIndex() + 1; + } + } + + next(isAutomatic: boolean = false) { + this.lastAdvanceTimestamp = Date.now(); + this.audioEngine.resetWordCount(); + this.wordsSpokenInCurrentPage = 0; + + const totalLines = this.getTotalLines(); + const prevIdx = this.currentLineIndex(); + + if (prevIdx < totalLines - 1) { + let nextIdx: number; + if (isAutomatic) { + if (prevIdx === 0) { + // Sulla prima pagina, avanziamo alla metà della pagina (inizio della seconda metà) + const visibleIndices = this.getVisibleLineIndices(); + if (visibleIndices.length > 0) { + const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2)); + nextIdx = Math.min(totalLines - 1, visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength); + } else { + const pageSize = this.calculatePageStepSize(); + const halfPageSize = Math.max(1, Math.ceil(pageSize / 2)); + nextIdx = Math.min(totalLines - 1, prevIdx + halfPageSize); + } + } else { + // Avanzamento acustico automatico: avanza l'intera pagina lasciando centrale la riga di riferimento + nextIdx = this.calculateNextPageStartIndex(); + } + this.lastScrollBlock.set('center'); + } else if (this.settingsService.karaokePageScrollMode()) { + // Modalità manuale a pagine: calcolo analitico preciso per non perdere righe coperte + nextIdx = this.calculateNextPageStartIndex(); + this.lastScrollBlock.set('start'); + } else { + // Avanzamento riga per riga standard manuale + nextIdx = prevIdx + 1; + this.lastScrollBlock.set('center'); + } + + this.currentLineIndex.set(nextIdx); + this.channel.postMessage({ type: 'SYNC_INDEX', index: nextIdx }); + } + } + + // Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti + prev() { this.lastAdvanceTimestamp = Date.now(); this.audioEngine.resetWordCount(); - if (this.currentLineIndex() > 0) { - this.currentLineIndex.set(this.currentLineIndex() - 1); - this.channel.postMessage({ type: 'SYNC_INDEX', index: this.currentLineIndex() }); + this.wordsSpokenInCurrentPage = 0; + + const prevIdx = this.currentLineIndex(); + if (prevIdx > 0) { + const isPageMode = this.settingsService.karaokePageScrollMode(); + const stepSize = isPageMode ? this.calculatePageStepSize() : 1; + const nextIdx = Math.max(0, prevIdx - stepSize); + + if (nextIdx === 0) { + // Se torniamo all'inizio, allineiamo in alto + this.lastScrollBlock.set('start'); + } else if (isPageMode) { + this.lastScrollBlock.set('start'); + } else { + this.lastScrollBlock.set('center'); + } + + this.currentLineIndex.set(nextIdx); + this.channel.postMessage({ type: 'SYNC_INDEX', index: nextIdx }); } } diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index 818607f..d718863 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -134,6 +134,14 @@ + + + +

Avanzamento manuale a pagine

+

I tasti Avanti/Indietro (Karaoke) voltano la pagina intera anziché riga per riga

+
+ +
diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index a3a2cc5..8058c37 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -43,6 +43,26 @@ export class SettingsPage { constructor() {} + private async forceBypassCacheAndCheck(): Promise { + try { + // 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.update(); + console.log('[PWA-Update] Native Service Worker updated from settings'); + } + } + // 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali + await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' }); + await fetch('/ngsw.json', { cache: 'reload' }); + console.log('[PWA-Update] Caches successfully busted for ngsw.json'); + } catch (e) { + console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e); + } + return await this.swUpdate.checkForUpdate(); + } + async installApp() { await this.settingsService.installPwa(); } @@ -83,7 +103,7 @@ export class SettingsPage { // 4. Check for Service Worker updates if (this.swUpdate.isEnabled) { try { - const updateFound = await this.swUpdate.checkForUpdate(); + const updateFound = await this.forceBypassCacheAndCheck(); if (updateFound) { const toast = await this.toastCtrl.create({ message: 'Nuova versione disponibile! Aggiornamento in corso...', @@ -143,7 +163,7 @@ export class SettingsPage { await toastLoading.present(); try { - const updateFound = await this.swUpdate.checkForUpdate(); + const updateFound = await this.forceBypassCacheAndCheck(); if (updateFound) { const toast = await this.toastCtrl.create({ message: 'Nuova versione trovata! Installazione e attivazione in corso...', diff --git a/src/app/services/audio-engine.service.ts b/src/app/services/audio-engine.service.ts index 4eee77a..3914943 100644 --- a/src/app/services/audio-engine.service.ts +++ b/src/app/services/audio-engine.service.ts @@ -11,12 +11,21 @@ export class AudioEngineService { public searchTranscript = signal(''); public isSearching = signal(false); public sensitivity = signal(75); // Default sensitivity (50-100) + public speechRecognitionActive = signal(false); + + // Real-time background continuous transcript and word buffer for reliability checks + public backgroundTranscript = signal(''); + private backgroundRecognition: any = null; + private recentWordsBuffer: { word: string, timestamp: number }[] = []; public clearSearchTranscript() { this.searchTranscript.set(''); } private audioContext: AudioContext | null = null; + private highpassFilter: BiquadFilterNode | null = null; + private lowpassFilter: BiquadFilterNode | null = null; + private vocalBandpass: BiquadFilterNode | null = null; private analyser: AnalyserNode | null = null; private stream: MediaStream | null = null; private animationFrame: number | null = null; @@ -33,9 +42,118 @@ export class AudioEngineService { private readonly COOLDOWN = 1000; // ms private peakEnergy: number = 0; private lineStarted: boolean = false; + private noiseFloor: number = 30; // Adaptive noise floor starting point constructor() { this.initSpeechRecognition(); + this.initBackgroundRecognition(); + } + + private initBackgroundRecognition() { + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (SpeechRecognition) { + this.backgroundRecognition = new SpeechRecognition(); + this.backgroundRecognition.continuous = true; + this.backgroundRecognition.interimResults = true; + this.backgroundRecognition.lang = 'it-IT'; + + this.backgroundRecognition.onstart = () => { + console.log('Background speech recognition started successfully'); + this.speechRecognitionActive.set(true); + }; + + this.backgroundRecognition.onresult = (event: any) => { + let interimTranscript = ''; + let finalTranscript = ''; + for (let i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + finalTranscript += event.results[i][0].transcript; + } else { + interimTranscript += event.results[i][0].transcript; + } + } + const fullTranscript = (finalTranscript + ' ' + interimTranscript).trim().toLowerCase(); + if (fullTranscript) { + this.backgroundTranscript.set(fullTranscript); + this.updateRecentWords(fullTranscript); + } + }; + + this.backgroundRecognition.onerror = (err: any) => { + console.error('Background recognition error:', err); + this.speechRecognitionActive.set(false); + if (this.isListening() && err.error !== 'aborted' && err.error !== 'not-allowed') { + setTimeout(() => this.startBackgroundRecognition(), 1000); + } + }; + + this.backgroundRecognition.onend = () => { + console.log('Background speech recognition ended'); + if (this.isListening()) { + this.startBackgroundRecognition(); + } else { + this.speechRecognitionActive.set(false); + } + }; + } + } + + private startBackgroundRecognition() { + if (!this.backgroundRecognition) return; + try { + this.backgroundRecognition.start(); + } catch (e) { + // Recognition already started or running + } + } + + private stopBackgroundRecognition() { + if (!this.backgroundRecognition) return; + try { + this.backgroundRecognition.stop(); + } catch (e) { + // Recognition already stopped + } + } + + private updateRecentWords(transcript: string) { + const words = transcript.split(/\s+/); + const now = Date.now(); + words.forEach(w => { + // Strip special characters and keep alphanumeric Italian letters + const cleaned = w.replace(/[^a-zA-Z0-9àèìòùáéíóú]/g, '').toLowerCase(); + if (cleaned.length >= 3) { + // Avoid adding duplicate words within the last 3 seconds + const existing = this.recentWordsBuffer.find(item => item.word === cleaned && now - item.timestamp < 3000); + if (!existing) { + this.recentWordsBuffer.push({ word: cleaned, timestamp: now }); + } + } + }); + + // Keep only words from the last 10 seconds to make it highly responsive + this.recentWordsBuffer = this.recentWordsBuffer.filter(item => now - item.timestamp < 10000); + } + + public getRecentWords(): string[] { + const now = Date.now(); + this.recentWordsBuffer = this.recentWordsBuffer.filter(item => now - item.timestamp < 10000); + return this.recentWordsBuffer.map(item => item.word); + } + + public getRecentWordsSince(timestamp: number): string[] { + const now = Date.now(); + // Consente una sovrapposizione di 1.5 secondi per coprire la latenza di SpeechRecognition + const cutoff = Math.max(timestamp - 1500, now - 10000); + this.recentWordsBuffer = this.recentWordsBuffer.filter(item => now - item.timestamp < 10000); + return this.recentWordsBuffer + .filter(item => item.timestamp > cutoff) + .map(item => item.word); + } + + public clearRecentWords() { + this.recentWordsBuffer = []; + this.backgroundTranscript.set(''); } private initSpeechRecognition() { @@ -89,16 +207,38 @@ export class AudioEngineService { audio: { echoCancellation: true, noiseSuppression: true, - autoGainControl: false // Prevents boosting background noise during silence + autoGainControl: false // Impedisce il boost automatico del rumore di fondo nel silenzio } }); this.audioContext = new AudioContext(); const source = this.audioContext.createMediaStreamSource(this.stream); + + // Catena di filtri in cascata per isolamento vocale + this.highpassFilter = this.audioContext.createBiquadFilter(); + this.highpassFilter.type = 'highpass'; + this.highpassFilter.frequency.value = 150; // Taglia i bassi e rombi di fondo + + this.lowpassFilter = this.audioContext.createBiquadFilter(); + this.lowpassFilter.type = 'lowpass'; + this.lowpassFilter.frequency.value = 2000; // Taglia sibili e armonici acuti di chitarre + + this.vocalBandpass = this.audioContext.createBiquadFilter(); + this.vocalBandpass.type = 'bandpass'; + this.vocalBandpass.frequency.value = 800; // Centro della gamma vocale primaria + this.vocalBandpass.Q.value = 1.2; // Filtro stretto focalizzato sulla voce + this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; - source.connect(this.analyser); + // Connessione in cascata: source -> highpass -> lowpass -> bandpass -> analyser + source.connect(this.highpassFilter); + this.highpassFilter.connect(this.lowpassFilter); + this.lowpassFilter.connect(this.vocalBandpass); + this.vocalBandpass.connect(this.analyser); + this.isListening.set(true); + this.clearRecentWords(); + this.startBackgroundRecognition(); this.processAudio(); } catch (err) { console.error('Error accessing microphone', err); @@ -112,6 +252,12 @@ export class AudioEngineService { this.audioContext?.close(); this.isListening.set(false); this.energyLevel.set(0); + this.stopBackgroundRecognition(); + this.clearRecentWords(); + this.highpassFilter = null; + this.lowpassFilter = null; + this.vocalBandpass = null; + this.speechRecognitionActive.set(false); } private processAudio() { @@ -120,20 +266,37 @@ export class AudioEngineService { const bufferLength = this.analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); + // Con la catena di filtri in cascata, l'energia vocale è centrata tra 200 Hz e 2000 Hz. + const sampleRate = this.audioContext ? this.audioContext.sampleRate : 44100; + const binWidth = sampleRate / 512; + const startBin = Math.max(0, Math.floor(200 / binWidth)); + const endBin = Math.min(bufferLength - 1, Math.floor(2000 / binWidth)); + const analyze = () => { - this.analyser!.getByteFrequencyData(dataArray); + if (!this.analyser) return; + this.analyser.getByteFrequencyData(dataArray); - // Calculate average energy (volume) + // Calcola l'energia media ESCLUSIVAMENTE nella gamma di frequenza della voce let sum = 0; - for (let i = 0; i < bufferLength; i++) { + for (let i = startBin; i <= endBin; i++) { sum += dataArray[i]; } - const avgEnergy = sum / bufferLength; + const avgEnergy = sum / (endBin - startBin + 1); this.energyLevel.set(avgEnergy); const now = Date.now(); - // Balanced mapping: 0% -> 220 (very quiet), 100% -> 20 (very sensitive) - const currentThreshold = 220 - (this.sensitivity() * 2.0); + + // Tracciamento adattivo del rumore di fondo + if (avgEnergy < this.noiseFloor) { + this.noiseFloor = this.noiseFloor * 0.95 + avgEnergy * 0.05; + } else { + this.noiseFloor = this.noiseFloor * 0.998 + avgEnergy * 0.002; + } + + // Soglia di sensibilità definita dall'utente + const userThreshold = 220 - (this.sensitivity() * 2.0); + // La soglia effettiva si adatta al rumore di fondo per evitare falsi avanzamenti + const currentThreshold = Math.max(userThreshold, this.noiseFloor + 12); if (avgEnergy > currentThreshold) { if (avgEnergy > this.peakEnergy) { @@ -146,23 +309,22 @@ export class AudioEngineService { this.lastSilenceTime = now; } - // Se siamo in "speaking" e sentiamo un calo significativo rispetto al picco recente (almeno 30% di calo) - // Questo permette di avanzare anche se c'è rumore di fondo sopra la soglia base. + // Rilevamento della caduta di energia relativa per identificare stacchi tra sillabe/parole (legato acustico) const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy; - if (this.isSpeaking && dropRatio > 0.35 && this.peakEnergy > currentThreshold * 1.2) { + if (this.isSpeaking && dropRatio > 0.20 && this.peakEnergy > currentThreshold * 1.1) { if (now - this.lastWordTime > this.COOLDOWN) { this.linesDetected.update(v => v + 1); this.lastWordTime = now; this.peakEnergy = avgEnergy; // Reset peak this.isSpeaking = false; - console.log('Line advanced - relative drop detected', { dropRatio, avgEnergy, peak: this.peakEnergy }); + console.log('Line advanced - relative drop detected (highly sensitive)', { dropRatio, avgEnergy, peak: this.peakEnergy }); } } this.lastSilenceTime = now; } else { - // Sotto soglia (silenzio per il sistema) - if (this.isSpeaking && (now - this.lastSilenceTime > 200)) { + // Sotto soglia (silenzio) + if (this.isSpeaking && (now - this.lastSilenceTime > 180)) { if (now - this.lastWordTime > this.COOLDOWN) { this.linesDetected.update(v => v + 1); this.lastWordTime = now; diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index a990ae2..19dcd69 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -50,6 +50,9 @@ export class SettingsService { /** Preferenza notazione accordi: diesis o bemolle */ public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); + /** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */ + public karaokePageScrollMode = signal(false); + private wakeLock: any = null; // PWA installation signals @@ -198,6 +201,13 @@ export class SettingsService { this.chordNotationPreference.set('diesis'); } + const savedKaraokePageScrollMode = localStorage.getItem('karaoke-page-scroll-mode'); + if (savedKaraokePageScrollMode !== null) { + this.karaokePageScrollMode.set(savedKaraokePageScrollMode === 'true'); + } else { + this.karaokePageScrollMode.set(false); + } + // Sync browser fullscreen state with listeners (supporting vendor prefixes) const updateFullscreenState = () => { const isFs = !!( @@ -270,6 +280,10 @@ export class SettingsService { localStorage.setItem('chord-notation-preference', this.chordNotationPreference()); }); + effect(() => { + localStorage.setItem('karaoke-page-scroll-mode', this.karaokePageScrollMode().toString()); + }); + effect(() => { const active = this.keepScreenOn(); localStorage.setItem('keep-screen-on', active.toString()); @@ -396,6 +410,12 @@ export class SettingsService { localStorage.setItem('enable-acoustic-autoscroll', newValue.toString()); } + toggleKaraokePageScrollMode() { + const newValue = !this.karaokePageScrollMode(); + this.karaokePageScrollMode.set(newValue); + localStorage.setItem('karaoke-page-scroll-mode', newValue.toString()); + } + setChordNotationPreference(val: 'diesis' | 'bemolle') { this.chordNotationPreference.set(val); } diff --git a/src/app/version.ts b/src/app/version.ts index 007f071..365f0ce 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.05.23.1602'; +export const VERSION = '2026.06.04.1906'; diff --git a/src/index.html b/src/index.html index 7b16e4b..4d16fda 100644 --- a/src/index.html +++ b/src/index.html @@ -7,6 +7,14 @@ + + + + + + + +