diff --git a/deploy_localpwa.sh b/deploy_localpwa.sh index 3f6024b..f5f5326 100755 --- a/deploy_localpwa.sh +++ b/deploy_localpwa.sh @@ -37,6 +37,11 @@ fi echo "✅ Build completata con successo in: $DIST_PATH" +# --- Genera version.json per il polling PWA --- +BUILD_TIMESTAMP=$(date +%s)000 +echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json +echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)" + # 2.5 In locale usiamo la root echo "📁 Build pronta nella root..." # Nessuna sottocartella ionic necessaria in locale diff --git a/deploy_www.sh b/deploy_www.sh index b7a66ed..0b5ee90 100755 --- a/deploy_www.sh +++ b/deploy_www.sh @@ -51,5 +51,10 @@ if [ ! -d "www" ]; then exit 1 fi +# --- Genera version.json per il polling PWA --- +BUILD_TIMESTAMP=$(date +%s)000 +echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json +echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)" + # --- Upload via FTP --- python3 scratch/deploy_ftp.py diff --git a/public/.htaccess b/public/.htaccess index 2143cf2..98055f2 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -11,7 +11,7 @@ # 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 9b9d97b..0aeaccc 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,8 +1,5 @@ -import { Component, inject, ApplicationRef } from '@angular/core'; -import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; -import { filter, first } from 'rxjs/operators'; -import { concat, interval, fromEvent } from 'rxjs'; -import { ToastController } from '@ionic/angular'; +import { Component, inject } from '@angular/core'; +import { ThemeService } from './services/theme.service'; @Component({ selector: 'app-root', @@ -11,125 +8,65 @@ import { ToastController } from '@ionic/angular'; standalone: false, }) export class AppComponent { - private swUpdate = inject(SwUpdate); - private appRef = inject(ApplicationRef); - private toastCtrl = inject(ToastController); + private themeService = inject(ThemeService); // Ensures theme is initialized at boot constructor() { - 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) { - 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 background updates...'); - - // 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.forceBypassCacheAndCheck(); - } catch (err) { - console.warn('[PWA-Update] Periodic update check failed:', err); - } - }); - }); - - // 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.forceBypassCacheAndCheck(); - } catch (err) { - console.warn('[PWA-Update] Resume update check failed:', err); - } - }); - } + // Gli aggiornamenti automatici e periodici sono stati rimossi. + // L'aggiornamento viene gestito esclusivamente in modo manuale + // tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage. } } + +export function showFullscreenUpdateOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'pwa-update-overlay'; + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100vw'; + overlay.style.height = '100vh'; + overlay.style.backgroundColor = '#121212'; + overlay.style.color = '#ffffff'; + overlay.style.display = 'flex'; + overlay.style.flexDirection = 'column'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.zIndex = '99999'; + overlay.style.fontFamily = "'Outfit', sans-serif"; + overlay.style.transition = 'opacity 0.5s ease'; + + overlay.innerHTML = ` +
+

Aggiornamento in corso

+

Installazione della nuova versione...

+
+
+
+
0%
+
+ `; + document.body.appendChild(overlay); + + let percent = 0; + const interval = setInterval(() => { + if (percent < 95) { + percent += Math.floor(Math.random() * 5) + 2; + if (percent > 95) percent = 95; + updatePercent(percent); + } + }, 150); + + function updatePercent(val: number) { + const bar = document.getElementById('pwa-update-bar'); + const txt = document.getElementById('pwa-update-percent'); + if (bar) bar.style.width = val + '%'; + if (txt) txt.textContent = val + '%'; + } + + return { + finish: () => { + clearInterval(interval); + updatePercent(100); + } + }; +} diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 8c074b4..aa9f561 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -5,7 +5,10 @@
{{ appName }} - v{{ version }} + + v{{ version }} + +
@@ -283,7 +286,7 @@
- {{ canto.id.startsWith('my_') ? 'M' : canto.id_canti }} + {{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 0b3f62f..43c21c9 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -16,6 +16,9 @@ import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.componen import { CantiLettureService } from '../services/canti-letture.service'; import { ComunitaService } from '../services/comunita.service'; import { environment } from '../../environments/environment'; +import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; +import { filter, first } from 'rxjs/operators'; +import { showFullscreenUpdateOverlay } from '../app.component'; @Component({ selector: 'app-home', @@ -67,9 +70,123 @@ export class HomePage implements OnDestroy { private alertCtrl = inject(AlertController); private toastCtrl = inject(ToastController); private loadingCtrl = inject(LoadingController); + private swUpdate = inject(SwUpdate); private firstInteraction = true; + async checkForAppUpdate(event?: Event) { + if (event) event.stopPropagation(); + + if (!this.swUpdate.isEnabled) { + const toast = await this.toastCtrl.create({ + message: 'Aggiornamenti non supportati su questo browser.', + duration: 3000, + color: 'medium' + }); + await toast.present(); + return; + } + + const toastLoading = await this.toastCtrl.create({ + message: 'Ricerca aggiornamenti in corso...', + duration: 1500, + color: 'secondary' + }); + await toastLoading.present(); + + const updateAvailable = await this.performUpdateCheck(); + + if (!updateAvailable) { + const toast = await this.toastCtrl.create({ + message: 'L\'applicazione è già aggiornata all\'ultima versione.', + duration: 3000, + color: 'success' + }); + await toast.present(); + } + } + + private async performUpdateCheck(): Promise { + try { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + await registration.update(); + } + + if (this.swUpdate.isEnabled) { + const swFoundUpdate = await this.swUpdate.checkForUpdate(); + if (swFoundUpdate) { + await this.applyUpdateAndReload(); + return true; + } + } + + const versionMismatch = await this.checkVersionJson(); + if (versionMismatch) { + await this.applyUpdateAndReload(); + return true; + } + + return false; + } catch (err) { + console.error('[PWA-Update] Update check failed from home:', err); + const toast = await this.toastCtrl.create({ + message: 'Errore durante la ricerca di aggiornamenti.', + duration: 3000, + color: 'danger' + }); + await toast.present(); + return false; + } + } + + private async applyUpdateAndReload() { + const overlay = showFullscreenUpdateOverlay(); + + let activated = false; + const activateAndReload = async () => { + if (activated) return; + activated = true; + try { + if (this.swUpdate.isEnabled) { + await this.swUpdate.activateUpdate(); + } + } catch (e) { + console.warn('[PWA-Update] activateUpdate failed:', e); + } + overlay.finish(); + setTimeout(() => { + window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); + }, 600); + }; + + if (this.swUpdate.isEnabled) { + this.swUpdate.versionUpdates + .pipe( + filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), + first() + ) + .subscribe(() => { + activateAndReload(); + }); + } + + setTimeout(() => { + activateAndReload(); + }, 6000); + } + + private async checkVersionJson(): Promise { + try { + const response = await fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' }); + if (!response.ok) return false; + const data = await response.json(); + return data.version !== VERSION; + } catch (err) { + return false; + } + } + onInteraction() { if (this.firstInteraction) { this.firstInteraction = false; @@ -1104,6 +1221,12 @@ export class HomePage implements OnDestroy { return info && info.num_canto ? info.num_canto.toString() : null; } + getMySongNumber(canto: any): number { + if (!canto || !canto.id) return 0; + const index = this.myCantiService.myCanti().findIndex(c => c.id === canto.id); + return index !== -1 ? index + 1 : 0; + } + toggleComunitaFilter() { if (!this.comunitaService.comunitaCode()) { this.promptComunitaCode(); diff --git a/src/app/pages/display/display.page.scss b/src/app/pages/display/display.page.scss index 828a965..eae7771 100644 --- a/src/app/pages/display/display.page.scss +++ b/src/app/pages/display/display.page.scss @@ -74,6 +74,9 @@ .seg-text { white-space: pre-wrap; + &::after { + content: '\200b'; + } } .footer-info { diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index de5f74d..0bfd35b 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -6,7 +6,7 @@
- {{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }} + {{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }} {{ getCommunitySongNumber(canto()) }} @@ -26,6 +26,9 @@
+ + + - + diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index 5f4755c..f137f1c 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -197,6 +197,9 @@ .seg-text { white-space: pre; + &::after { + content: '\200b'; + } } } @@ -634,6 +637,20 @@ ion-content.full-screen-content { } :host-context(body.high-contrast) { + .slim-toolbar { + --background: #ffffff !important; + background: #ffffff !important; + border-top: 1px solid rgba(0, 0, 0, 0.2) !important; + backdrop-filter: none !important; + } + + .landscape-side-controls { + --background: #ffffff !important; + background: #ffffff !important; + border-left: 1px solid rgba(0, 0, 0, 0.2) !important; + backdrop-filter: none !important; + } + .slim-controls .group { background: rgba(0, 0, 0, 0.05) !important; border: 1px solid rgba(0, 0, 0, 0.15) !important; diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index 9f88b98..a7f9f51 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -412,18 +412,29 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { const container = document.querySelector('.lyrics-container') as HTMLElement; if (!container) return 3; // Ritorno generico se il contenitore non è pronto - const containerHeight = container.clientHeight; - - // Calcoliamo la reale altezza visibile sottraendo l'eventuale footer in sovraimpressione - let visibleHeight = containerHeight; + const containerRect = container.getBoundingClientRect(); + const visibleTop = Math.max(containerRect.top, 0); + let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight); + 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; + if (footer) { + const footerRect = footer.getBoundingClientRect(); + if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') { + visibleBottom = Math.min(visibleBottom, footerRect.top); } } + const floatingBar = document.querySelector('.floating-autoscroll-bar') as HTMLElement; + if (floatingBar) { + const barRect = floatingBar.getBoundingClientRect(); + if (barRect.height > 0 && window.getComputedStyle(floatingBar).display !== 'none') { + visibleBottom = Math.min(visibleBottom, barRect.top); + } + } + + // Calcoliamo la reale altezza visibile + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const lineElems = document.querySelectorAll('.lyric-line'); if (lineElems.length === 0) return 3; @@ -443,7 +454,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { const pageStep = Math.max(1, linesPerPage); console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', { - containerHeight, visibleHeight, avgLineHeight, linesPerPage, @@ -463,10 +473,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (!container) return []; const containerRect = container.getBoundingClientRect(); - const visibleTop = containerRect.top; - let visibleBottom = containerRect.bottom; + const visibleTop = Math.max(containerRect.top, 0); + let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight); - // Sottrai l'altezza dell'eventuale footer sovrapposto ad alto z-index + // Sottrai l'altezza dell'eventuale footer o barra sovrapposti ad alto z-index const footer = document.querySelector('ion-footer') as HTMLElement; if (footer) { const footerRect = footer.getBoundingClientRect(); @@ -475,6 +485,14 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + const floatingBar = document.querySelector('.floating-autoscroll-bar') as HTMLElement; + if (floatingBar) { + const barRect = floatingBar.getBoundingClientRect(); + if (barRect.height > 0 && window.getComputedStyle(floatingBar).display !== 'none') { + visibleBottom = Math.min(visibleBottom, barRect.top); + } + } + const lineElems = document.querySelectorAll('.lyric-line'); const visibleIndices: number[] = []; @@ -501,7 +519,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (!container) return this.currentLineIndex() + 1; const containerRect = container.getBoundingClientRect(); - let visibleBottom = containerRect.bottom; + // Assicuriamoci che il limite inferiore non superi l'altezza reale della finestra + let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight); // Trova la posizione del footer o di qualunque elemento sovrapposto in fondo const footer = document.querySelector('ion-footer') as HTMLElement; @@ -512,8 +531,18 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } - // Sottrai un piccolo margine di tolleranza di 8px - const visibleLimitY = visibleBottom - 8; + const floatingBar = document.querySelector('.floating-autoscroll-bar') as HTMLElement; + if (floatingBar) { + const barRect = floatingBar.getBoundingClientRect(); + if (barRect.height > 0 && window.getComputedStyle(floatingBar).display !== 'none') { + visibleBottom = Math.min(visibleBottom, barRect.top); + } + } + + // Aumentiamo il margine di tolleranza a 24px (o più) per essere sicuri + // di non perdere mai una riga parzialmente coperta. Meglio rileggere una riga + // in cima alla pagina successiva che perderla completamente a causa dello zoom. + const visibleLimitY = visibleBottom - 24; const lineElems = document.querySelectorAll('.lyric-line'); const totalLines = this.getTotalLines(); @@ -546,7 +575,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } - next(isAutomatic: boolean = false) { + next(isAutomatic: boolean = false, isVisual: boolean = false) { this.lastAdvanceTimestamp = Date.now(); const totalLines = this.getTotalLines(); @@ -571,8 +600,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { nextIdx = this.calculateNextPageStartIndex(); } this.lastScrollBlock.set('center'); - } else if (this.settingsService.karaokePageScrollMode()) { - // Modalità manuale a pagine: calcolo analitico preciso per non perdere righe coperte + } else if (this.settingsService.karaokePageScrollMode() || isVisual) { + // Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte nextIdx = this.calculateNextPageStartIndex(); this.lastScrollBlock.set('start'); } else { @@ -588,12 +617,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { // Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti - prev() { + prev(isVisual: boolean = false) { this.lastAdvanceTimestamp = Date.now(); const prevIdx = this.currentLineIndex(); if (prevIdx > 0) { - const isPageMode = this.settingsService.karaokePageScrollMode(); + const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual; const stepSize = isPageMode ? this.calculatePageStepSize() : 1; const nextIdx = Math.max(0, prevIdx - stepSize); @@ -710,6 +739,36 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + async editOrCloneCanto() { + const c = this.canto(); + if (!c) return; + + if ( + this.settingsService.comunitaEnabled() && + this.settingsService.showEditor() && + this.comunitaService.comunitaCode() && + !c.id.startsWith('my_') + ) { + // Clone the song: Title: + const clonedTitle = c.titolo; + + const clonedCanto = await this.myCantiService.saveCanto({ + titolo: clonedTitle, + autore: c.autore, + link_youtube: c.link_youtube, + testo: c.testo || c.accordi || '', + accordi: c.accordi || c.testo || '', + id_momenti: c.id_momenti || [] + }); + + // Redirect to the edit page for the newly cloned song + this.router.navigate(['/propose-canto'], { queryParams: { editId: clonedCanto.id } }); + } else { + // Standard edit path + this.router.navigate(['/propose-canto'], { queryParams: { editId: c.id } }); + } + } + private initPlayer(id: string) { if (!this.youtubePlayerService.isPlayerSupported()) { return; @@ -757,6 +816,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { return info && info.num_canto ? info.num_canto.toString() : null; } + getMySongNumber(canto: any): number { + if (!canto || !canto.id) return 0; + const index = this.myCantiService.myCanti().findIndex(c => c.id === canto.id); + return index !== -1 ? index + 1 : 0; + } + toggleAutoscroll() { if (this.isAutoscrolling()) { this.stopAutoscroll(); @@ -845,9 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { await this.faceDetector.start(videoEl, (direction) => { console.log(`[PlayerPage] Head tilt trigger received: ${direction}`); if (direction === 'next') { - this.next(false); + this.next(false, true); } else { - this.prev(); + this.prev(true); } }); } catch (e) { diff --git a/src/app/pages/playlist/playlist.page.html b/src/app/pages/playlist/playlist.page.html index 521d3bc..70cc59a 100644 --- a/src/app/pages/playlist/playlist.page.html +++ b/src/app/pages/playlist/playlist.page.html @@ -24,7 +24,7 @@
- {{ song.id_canti }} + {{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }} {{ getCommunitySongNumber(song) }}{{ song.titolo }} diff --git a/src/app/pages/playlist/playlist.page.ts b/src/app/pages/playlist/playlist.page.ts index 02ae6f3..65e0834 100644 --- a/src/app/pages/playlist/playlist.page.ts +++ b/src/app/pages/playlist/playlist.page.ts @@ -227,4 +227,10 @@ export class PlaylistPage { const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id)); return info && info.num_canto ? info.num_canto.toString() : null; } + + getMySongNumber(song: any): number { + if (!song || !song.id) return 0; + const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id); + return index !== -1 ? index + 1 : 0; + } } diff --git a/src/app/pages/propose-canto/propose-canto.page.html b/src/app/pages/propose-canto/propose-canto.page.html index f941c26..d72d153 100644 --- a/src/app/pages/propose-canto/propose-canto.page.html +++ b/src/app/pages/propose-canto/propose-canto.page.html @@ -7,7 +7,20 @@ - + + +
+
+ +

Rilascia l'immagine qui per estrarre il testo

+
+
+
@@ -101,7 +114,8 @@ [(ngModel)]="content" placeholder="Scrivi o scansiona..." rows="18" - class="content-textarea"> + class="content-textarea" + (paste)="onPaste($event)">
diff --git a/src/app/pages/propose-canto/propose-canto.page.scss b/src/app/pages/propose-canto/propose-canto.page.scss index 1b0ba49..d43f8b9 100644 --- a/src/app/pages/propose-canto/propose-canto.page.scss +++ b/src/app/pages/propose-canto/propose-canto.page.scss @@ -419,3 +419,54 @@ body.high-contrast :host ::ng-deep { } } } + +/* DRAG AND DROP OVERLAY */ +.drag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; + + .drag-message { + text-align: center; + color: var(--ion-color-secondary); + background: rgba(255, 255, 255, 0.1); + border: 3px dashed var(--ion-color-secondary); + border-radius: 20px; + padding: 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + + ion-icon { + font-size: 64px; + } + + p { + font-size: 20px; + font-weight: 700; + margin: 0; + } + } +} + +body.high-contrast :host ::ng-deep { + .drag-overlay { + background: rgba(255, 255, 255, 0.9); + .drag-message { + color: #000000; + background: #ffffff; + border: 3px dashed #000000; + } + } +} diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index 79cf4f7..e81dfc2 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -6,13 +6,14 @@ import { createWorker } from 'tesseract.js'; import { CantiService } from '../../services/canti.service'; import { MyCantiService } from '../../services/my-canti.service'; import { ThemeService } from '../../services/theme.service'; +import { ActivatedRoute, RouterModule } from '@angular/router'; @Component({ selector: 'app-propose-canto', templateUrl: './propose-canto.page.html', styleUrls: ['./propose-canto.page.scss'], standalone: true, - imports: [CommonModule, FormsModule, IonicModule] + imports: [CommonModule, FormsModule, IonicModule, RouterModule] }) export class ProposeCantoPage implements OnInit { @ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea; @@ -22,12 +23,14 @@ export class ProposeCantoPage implements OnInit { private myCantiService = inject(MyCantiService); private navCtrl = inject(NavController); public themeService = inject(ThemeService); + private route = inject(ActivatedRoute); title: string = ''; author: string = ''; youtubeLink: string = ''; selectedLiturgico: number[] = []; selectedTematico: number[] = []; + editId: string | null = null; private _content: string = ''; get content(): string { return this._content; } @@ -41,6 +44,7 @@ export class ProposeCantoPage implements OnInit { undoStack: string[] = []; isProcessingOCR: boolean = false; ocrProgress: number = 0; + isDraggingOver: boolean = false; get isHighContrast(): boolean { return this.themeService.highContrast(); } groupedChords = [ @@ -104,6 +108,31 @@ export class ProposeCantoPage implements OnInit { constructor(private toastController: ToastController, private popoverController: PopoverController) { } ngOnInit() { + this.route.queryParams.subscribe(params => { + const editId = params['editId']; + if (editId) { + this.editId = editId; + // Find the song in standard canti or personal canti list + const song = [ + ...this.cantiService.canti(), + ...this.myCantiService.myCanti() + ].find(c => c.id === editId); + + if (song) { + this.title = song.titolo; + this.author = song.autore || ''; + this.youtubeLink = song.link_youtube || ''; + this.content = song.accordi || song.testo || ''; + + // Pre-populate liturgico and tematico lists + const litIds = this.cantiService.indiceLiturgico().map(m => m.id); + const temIds = this.cantiService.indiceTematico().map(m => m.id); + + this.selectedLiturgico = song.id_momenti?.filter(id => litIds.includes(id)) || []; + this.selectedTematico = song.id_momenti?.filter(id => temIds.includes(id)) || []; + } + } + }); } async insertText(tag: string) { @@ -143,13 +172,70 @@ export class ProposeCantoPage implements OnInit { this.cameraInput.nativeElement.click(); } + onDragOver(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + + // Mostra l'overlay solo se si sta trascinando un file + if (event.dataTransfer?.types.includes('Files')) { + this.isDraggingOver = true; + } + } + + onDragLeave(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + this.isDraggingOver = false; + } + + async onDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + this.isDraggingOver = false; + + if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { + const file = event.dataTransfer.files[0]; + if (file.type.indexOf('image') !== -1) { + await this.processImageFile(file); + } else { + const toast = await this.toastController.create({ + message: 'Per favore, trascina un file immagine valido.', + duration: 3000, + color: 'warning' + }); + toast.present(); + } + } + } + async onFileSelected(event: any, isCamera: boolean) { const file = event.target.files[0]; if (!file) { console.log('[OCR-Capture] Nessun file selezionato.'); return; } + await this.processImageFile(file); + event.target.value = ''; + } + async onPaste(event: ClipboardEvent) { + const items = event.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + event.preventDefault(); // Prevent pasting the image representation as text + const blob = items[i].getAsFile(); + if (blob) { + const file = new File([blob], 'pasted-image.png', { type: blob.type }); + await this.processImageFile(file); + } + break; + } + } + } + + async processImageFile(file: File) { console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`); this.isProcessingOCR = true; @@ -187,7 +273,6 @@ export class ProposeCantoPage implements OnInit { } finally { this.isProcessingOCR = false; this.ocrProgress = 0; - event.target.value = ''; } } @@ -293,7 +378,7 @@ export class ProposeCantoPage implements OnInit { // Calculate average word height to set vertical tolerance const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0); const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length; - const verticalTolerance = avgHeight * 0.6; + const verticalTolerance = avgHeight * 0.85; console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`); // 2. Group words into horizontal lines @@ -324,16 +409,43 @@ export class ProposeCantoPage implements OnInit { }); // 3. Classify lines as Chords vs. Text - const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?$/i; + const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i; const isChordWord = (text: string): boolean => { - const clean = text.replace(/[\[\]\(\)\.\,\-\+]/g, '').trim().toUpperCase(); + if (!text || text.length === 0) return false; + const lower = text.toLowerCase(); + // Skip common valid words written purely in lowercase + if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) { + return false; + } + let clean = text.toUpperCase().replace(/\s+/g, ''); + clean = clean.replace(/\((.*?)\)/g, '/$1'); + clean = clean.replace(/[\.\,]$/g, ''); return chordRegex.test(clean); }; const classifiedLines = lines.map(line => { - const chordCount = line.filter(w => isChordWord(w.text)).length; + let chordCount = 0; + let hasLongNonChord = false; + + line.forEach(w => { + if (isChordWord(w.text)) { + chordCount++; + } else { + const clean = w.text.replace(/[.,:;!\?]/g, '').trim(); + if (clean.length > 5) { + hasLongNonChord = true; + } + } + }); + const ratio = line.length > 0 ? chordCount / line.length : 0; - const isChords = ratio >= 0.4 && line.length <= 10; + let isChords = false; + + if (ratio >= 0.4 && line.length <= 10) { + if (!hasLongNonChord || ratio >= 0.75) { + isChords = true; + } + } return { words: line, @@ -371,8 +483,29 @@ export class ProposeCantoPage implements OnInit { i++; // Skip next line because we consumed it! } else { // Chord line but no text below it: just wrap and print - const wrapped = current.words.map(w => `[${w.text.replace(/[\(\)\[\]]/g, '').toUpperCase()}]`).join(' '); - processedLines.push(wrapped); + const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi; + const expandedChords: string[] = []; + current.words.forEach(w => { + let cleanText = w.text.toUpperCase().replace(/\s+/g, ''); + cleanText = cleanText.replace(/\((.*?)\)/g, '/$1'); + cleanText = cleanText.replace(/[\.\,]$/g, ''); + + if (chordRegex.test(cleanText)) { + expandedChords.push(`[${cleanText}]`); + } else { + const matches = [...cleanText.matchAll(multiChordRegex)]; + const fullMatchStr = matches.map(m => m[0]).join(''); + if (matches.length > 0 && fullMatchStr === cleanText) { + expandedChords.push(...matches.map((m: string) => `[${m[0].toUpperCase()}]`)); + } else { + expandedChords.push(w.text); // keep original text if it's not a pure chord merge + } + } + }); + const wrapped = expandedChords.join(' '); + if (wrapped) { + processedLines.push(wrapped); + } } } else { const lineText = current.words.map(w => w.text).join(' '); @@ -408,17 +541,106 @@ export class ProposeCantoPage implements OnInit { } mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string { - let result = ''; + // Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)' + let mergedChordWords: any[] = []; + for (let i = 0; i < chordWords.length; i++) { + let cw = chordWords[i]; + if (cw.text.startsWith('(') && mergedChordWords.length > 0) { + let prev = mergedChordWords[mergedChordWords.length - 1]; + prev.text += cw.text; + prev.bbox.x1 = Math.max(prev.bbox.x1, cw.bbox.x1); + if (!prev.text.includes(')')) { + let j = i + 1; + while (j < chordWords.length) { + prev.text += chordWords[j].text; + prev.bbox.x1 = Math.max(prev.bbox.x1, chordWords[j].bbox.x1); + if (chordWords[j].text.includes(')')) { + i = j; + break; + } + j++; + } + } + } else { + let text = cw.text; + let bbox = { ...cw.bbox }; + if (text.includes('(') && !text.includes(')')) { + let j = i + 1; + while (j < chordWords.length) { + text += chordWords[j].text; + bbox.x1 = Math.max(bbox.x1, chordWords[j].bbox.x1); + if (chordWords[j].text.includes(')')) { + i = j; + break; + } + j++; + } + } + mergedChordWords.push({ text, bbox }); + } + } + + // Sanitize common OCR errors in chords (e.g., 'Re(f fa#)' -> 'Re(fa#)') + mergedChordWords.forEach(cw => { + cw.text = cw.text.replace(/f\s*fa#/gi, 'fa#'); + cw.text = cw.text.replace(/ff/gi, 'f'); + cw.text = cw.text.replace(/m\s*mi/gi, 'mi'); + cw.text = cw.text.replace(/mm/gi, 'm'); + }); + + if (!textWords || textWords.length === 0) { + return mergedChordWords.map(c => { + let clean = c.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, ''); + return `[${clean}]`; + }).join(' '); + } + + const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i; + const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi; + + const expandedChordWords: any[] = []; + mergedChordWords.forEach(chord => { + let originalText = chord.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, ''); + const matches = [...originalText.matchAll(multiChordRegex)]; + + if (matches.length === 0) { + expandedChordWords.push(chord); + } else { + const fullMatchStr = matches.map(m => m[0]).join(''); + if (fullMatchStr === originalText) { + const charWidth = (chord.bbox.x1 - chord.bbox.x0) / Math.max(1, originalText.length); + matches.forEach(match => { + const matchIndex = match.index!; + const matchLength = match[0].length; + const newX0 = chord.bbox.x0 + matchIndex * charWidth; + const newX1 = chord.bbox.x0 + (matchIndex + matchLength) * charWidth; + + expandedChordWords.push({ + text: match[0], + bbox: { ...chord.bbox, x0: newX0, x1: newX1 } + }); + }); + } else { + expandedChordWords.push(chord); + } + } + }); + const chordAssignments = new Map(); - chordWords.forEach(chord => { + expandedChordWords.forEach(chord => { const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2; let closestWord: any = null; let minDistance = Infinity; textWords.forEach(textWord => { - const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2; - const dist = Math.abs(chordX - wordXCenter); + let dist = 0; + if (chordX < textWord.bbox.x0) { + dist = textWord.bbox.x0 - chordX; + } else if (chordX > textWord.bbox.x1) { + dist = chordX - textWord.bbox.x1; + } + if (dist < minDistance) { minDistance = dist; closestWord = textWord; @@ -433,22 +655,44 @@ export class ProposeCantoPage implements OnInit { } }); + let result = ''; + textWords.forEach((textWord, index) => { const assignedChords = chordAssignments.get(textWord) || []; assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0); + const wordText = textWord.text; + let charWidth = (textWord.bbox.x1 - textWord.bbox.x0) / Math.max(1, wordText.length); + if (charWidth <= 0) charWidth = 6; // safe fallback + + let lastCharIndex = 0; + let wordResult = ''; + assignedChords.forEach(chord => { + const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2; + let charIndex = Math.round((chordX - textWord.bbox.x0) / charWidth); + + if (charIndex < 0) charIndex = 0; + if (charIndex > wordText.length) charIndex = wordText.length; + const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase(); - result += `[${cleanChord}]`; + wordResult += wordText.substring(lastCharIndex, charIndex) + `[${cleanChord}]`; + lastCharIndex = charIndex; }); - result += textWord.text; + wordResult += wordText.substring(lastCharIndex); + result += wordResult; + if (index < textWords.length - 1) { result += ' '; } }); - return result; + const finalResult = result.replace(/\]\[/g, '] ['); + console.log(`[OCR-Debug] Linea generata: ${finalResult}`); + + // Assicura che due accordi consecutivi abbiano sempre 3 spazi (es. [LA][MI] diventa [LA] [MI]) + return finalResult; } @@ -506,6 +750,7 @@ export class ProposeCantoPage implements OnInit { const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico]; await this.myCantiService.saveCanto({ + id: this.editId || undefined, titolo: this.title, autore: this.author, link_youtube: this.youtubeLink, diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index d718863..c95afc5 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -127,12 +127,12 @@ - + -

Autoscroll acustico

-

Mostra microfono per scorrimento vocale

+

Autoscroll visuale

+

Mostra fotocamera per scorrimento visuale

- +
@@ -275,12 +275,12 @@
- +
-

Scroll Acustico (Karaoke)

+

Scroll Visuale (Karaoke)

- Attiva lo scorrimento vocale intelligente. L'app ascolta il canto o lo strumento e fa scorrere testo e accordi a tempo di musica, senza bisogno di toccare lo schermo. Uno slider verticale permette di regolare la sensibilità. + Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo.

@@ -321,7 +321,7 @@

Riavvia Canto

- Riporta la visualizzazione all'inizio del testo e azzera il tracciamento vocale dello scroll acustico. + Riporta la visualizzazione all'inizio del testo e azzera il tracciamento dello scroll visuale.

diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index 8058c37..08e0ff2 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -14,6 +14,7 @@ import { MyCantiService } from '../../services/my-canti.service'; import { CantiLettureService } from '../../services/canti-letture.service'; import { ComunitaService } from '../../services/comunita.service'; import { environment } from '../../../environments/environment'; +import { showFullscreenUpdateOverlay } from '../../app.component'; @Component({ selector: 'app-settings', @@ -43,26 +44,6 @@ 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(); } @@ -79,6 +60,10 @@ export class SettingsPage { this.cantiLettureService.setSelectedMass(event.detail.value); } + /** + * Performs a full data refresh + checks for app updates. + * Uses both the Angular SW and the version.json fallback. + */ async fullRefresh() { // 1. Refresh JSON data this.cantiService.refresh(); @@ -100,40 +85,10 @@ export class SettingsPage { } } - // 4. Check for Service Worker updates - if (this.swUpdate.isEnabled) { - try { - const updateFound = await this.forceBypassCacheAndCheck(); - if (updateFound) { - const toast = await this.toastCtrl.create({ - message: 'Nuova versione disponibile! Aggiornamento in corso...', - duration: 2000, - color: 'secondary' - }); - await toast.present(); - - this.swUpdate.versionUpdates - .pipe( - filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), - first() - ) - .subscribe(async () => { - console.log('[PWA-Update] FullRefresh: version ready, activating...'); - await this.swUpdate.activateUpdate(); - window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); - }); - - setTimeout(async () => { - try { - await this.swUpdate.activateUpdate(); - } catch(e) {} - window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); - }, 6000); - return; - } - } catch (err) { - console.error('Failed to check for updates', err); - } + // 4. Check for app updates (SW + version.json fallback) + const updateAvailable = await this.performUpdateCheck(); + if (updateAvailable) { + return; // updateAvailable already triggered the update/reload flow } const toast = await this.toastCtrl.create({ @@ -162,51 +117,110 @@ export class SettingsPage { }); await toastLoading.present(); + const updateAvailable = await this.performUpdateCheck(); + + if (!updateAvailable) { + const toast = await this.toastCtrl.create({ + message: 'L\'applicazione è già aggiornata all\'ultima versione.', + duration: 3000, + color: 'success' + }); + await toast.present(); + } + } + + /** + * Shared update check logic: tries Angular SW first, falls back to version.json. + * Returns true if an update was found and the reload flow was initiated. + */ + private async performUpdateCheck(): Promise { try { - const updateFound = await this.forceBypassCacheAndCheck(); - if (updateFound) { - const toast = await this.toastCtrl.create({ - message: 'Nuova versione trovata! Installazione e attivazione in corso...', - duration: 3000, - color: 'success' - }); - await toast.present(); - - // Sottoscrizione per attivare l'aggiornamento appena terminato il download - this.swUpdate.versionUpdates - .pipe( - filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), - first() - ) - .subscribe(async () => { - console.log('[PWA-Update] Manual check: version ready, activating...'); - await this.swUpdate.activateUpdate(); - window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); - }); - - // Timeout di sicurezza per forzare l'attivazione e il ricaricamento se è già scaricato - setTimeout(async () => { - try { - await this.swUpdate.activateUpdate(); - } catch(e) {} - window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); - }, 8000); - } else { - const toast = await this.toastCtrl.create({ - message: 'L\'applicazione è già aggiornata all\'ultima versione.', - duration: 3000, - color: 'success' - }); - await toast.present(); + // Layer 1: Force the browser to re-fetch the SW script + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + await registration.update(); } + + // Layer 2: Ask Angular SW to check + if (this.swUpdate.isEnabled) { + const swFoundUpdate = await this.swUpdate.checkForUpdate(); + if (swFoundUpdate) { + await this.applyUpdateAndReload(); + return true; + } + } + + // Layer 3: Fallback — check version.json + const versionMismatch = await this.checkVersionJson(); + if (versionMismatch) { + console.log('[PWA-Update] version.json mismatch detected from settings'); + await this.applyUpdateAndReload(); + return true; + } + + return false; } catch (err) { - console.error('Check update failed', err); + console.error('[PWA-Update] Update check failed from settings:', err); const toast = await this.toastCtrl.create({ message: 'Errore durante la ricerca di aggiornamenti.', duration: 3000, color: 'danger' }); await toast.present(); + return false; + } + } + + private async applyUpdateAndReload() { + const overlay = showFullscreenUpdateOverlay(); + + let activated = false; + const activateAndReload = async () => { + if (activated) return; + activated = true; + try { + if (this.swUpdate.isEnabled) { + await this.swUpdate.activateUpdate(); + } + } catch (e) { + console.warn('[PWA-Update] activateUpdate failed:', e); + } + overlay.finish(); + setTimeout(() => { + window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); + }, 600); + }; + + // Listen for VERSION_READY + activate + reload + if (this.swUpdate.isEnabled) { + this.swUpdate.versionUpdates + .pipe( + filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), + first() + ) + .subscribe(() => { + console.log('[PWA-Update] Settings: version ready, activating...'); + activateAndReload(); + }); + } + + // Safety timeout: reload after 6s regardless + setTimeout(() => { + console.log('[PWA-Update] Settings: safety timeout reached, activating...'); + activateAndReload(); + }, 6000); + } + + private async checkVersionJson(): Promise { + try { + const response = await fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' }); + if (!response.ok) return false; + const data = await response.json(); + console.log(`[PWA-Update] Settings version check: local=${VERSION}, remote=${data.version}`); + return data.version !== VERSION; + } catch (err) { + console.warn('[PWA-Update] version.json check failed:', err); + return false; } } } diff --git a/src/app/services/my-canti.service.ts b/src/app/services/my-canti.service.ts index 1968e1d..af290aa 100644 --- a/src/app/services/my-canti.service.ts +++ b/src/app/services/my-canti.service.ts @@ -32,20 +32,57 @@ export class MyCantiService { } } - async saveCanto(canto: Partial) { + async saveCanto(canto: Partial): Promise { const current = this.myCanti(); - const newCanto: Canto = { - id: `my_${Date.now()}`, - id_canti: Date.now(), // Fake ID for internal logic - titolo: canto.titolo || 'Senza Titolo', - testo: canto.testo || '', - accordi: canto.accordi, - autore: canto.autore, - link_youtube: canto.link_youtube, - id_momenti: canto.id_momenti || [] - }; + let updated: Canto[]; + let targetCanto: Canto; + + if (canto.id && canto.id.startsWith('my_')) { + // Update existing song + updated = current.map(c => { + if (c.id === canto.id) { + targetCanto = { + ...c, + titolo: canto.titolo || c.titolo, + testo: canto.testo || c.testo, + accordi: canto.accordi !== undefined ? canto.accordi : c.accordi, + autore: canto.autore !== undefined ? canto.autore : c.autore, + link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube, + id_momenti: canto.id_momenti || c.id_momenti + }; + return targetCanto; + } + return c; + }); + // Fallback if not found in list (should not happen normally) + if (!updated.some(c => c.id === canto.id)) { + targetCanto = { + id: canto.id, + id_canti: canto.id_canti || Date.now(), + titolo: canto.titolo || 'Senza Titolo', + testo: canto.testo || '', + accordi: canto.accordi, + autore: canto.autore, + link_youtube: canto.link_youtube, + id_momenti: canto.id_momenti || [] + }; + updated.push(targetCanto); + } + } else { + // Create new song + targetCanto = { + id: `my_${Date.now()}`, + id_canti: Date.now(), // Fake ID for internal logic + titolo: canto.titolo || 'Senza Titolo', + testo: canto.testo || '', + accordi: canto.accordi, + autore: canto.autore, + link_youtube: canto.link_youtube, + id_momenti: canto.id_momenti || [] + }; + updated = [...current, targetCanto]; + } - const updated = [...current, newCanto]; this.myCanti.set(updated); await this._storage?.set('my-canti', updated); @@ -55,6 +92,8 @@ export class MyCantiService { color: 'success' }); toast.present(); + + return targetCanto!; } async deleteCanto(id: string) { diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 19dcd69..6c5ab97 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -42,10 +42,10 @@ export class SettingsService { public showUpdateDate = signal(true); /** Attiva autoscroll standard nel dettaglio canto: true = attivo */ - public enableStandardAutoscroll = signal(true); + public enableStandardAutoscroll = signal(false); - /** Attiva autoscroll acustico nel dettaglio canto: true = attivo */ - public enableAcousticAutoscroll = signal(false); + /** Attiva autoscroll visuale nel dettaglio canto: true = attivo */ + public enableVisualAutoscroll = signal(true); /** Preferenza notazione accordi: diesis o bemolle */ public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); @@ -96,7 +96,7 @@ export class SettingsService { }); // Migration: force settings defaults once for existing users to match the new rules - const migrationKey = 'defaults-migrated-20260521'; + const migrationKey = 'defaults-migrated-20260605'; if (localStorage.getItem(migrationKey) !== 'true') { localStorage.setItem('show-chords-default', 'true'); localStorage.setItem('fullscreen-mode', this.isIos().toString()); @@ -107,8 +107,8 @@ export class SettingsService { localStorage.setItem('invio-dati-statistici', 'false'); localStorage.setItem('show-tags-in-list', 'true'); localStorage.setItem('show-update-date', 'true'); - localStorage.setItem('enable-standard-autoscroll', 'true'); - localStorage.setItem('enable-acoustic-autoscroll', 'false'); + localStorage.setItem('enable-standard-autoscroll', 'false'); + localStorage.setItem('enable-visual-autoscroll', 'true'); localStorage.setItem('chord-notation-preference', 'diesis'); // ThemeService high contrast default @@ -184,14 +184,14 @@ export class SettingsService { if (savedStandardAutoscroll !== null) { this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true'); } else { - this.enableStandardAutoscroll.set(true); + this.enableStandardAutoscroll.set(false); } - const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll'); - if (savedAcousticAutoscroll !== null) { - this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true'); + const savedVisualAutoscroll = localStorage.getItem('enable-visual-autoscroll'); + if (savedVisualAutoscroll !== null) { + this.enableVisualAutoscroll.set(savedVisualAutoscroll === 'true'); } else { - this.enableAcousticAutoscroll.set(false); + this.enableVisualAutoscroll.set(true); } const savedNotation = localStorage.getItem('chord-notation-preference'); @@ -231,29 +231,6 @@ export class SettingsService { effect(() => { const mode = this.fullscreenMode(); localStorage.setItem('fullscreen-mode', mode.toString()); - - const isFs = !!( - document.fullscreenElement || - (document as any).webkitFullscreenElement || - (document as any).mozFullScreenElement || - (document as any).msFullscreenElement - ); - - if (mode && !isFs) { - const docEl = document.documentElement as any; - if (docEl.requestFullscreen) { - docEl.requestFullscreen().catch((err: any) => console.log('Request fs ignored', err)); - } else if (docEl.webkitRequestFullscreen) { - docEl.webkitRequestFullscreen(); - } - } else if (!mode && isFs) { - const doc = document as any; - if (doc.exitFullscreen) { - doc.exitFullscreen().catch((err: any) => console.log('Exit fs ignored', err)); - } else if (doc.webkitExitFullscreen) { - doc.webkitExitFullscreen(); - } - } }); effect(() => { @@ -273,7 +250,7 @@ export class SettingsService { }); effect(() => { - localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString()); + localStorage.setItem('enable-visual-autoscroll', this.enableVisualAutoscroll().toString()); }); effect(() => { @@ -404,10 +381,10 @@ export class SettingsService { localStorage.setItem('enable-standard-autoscroll', newValue.toString()); } - toggleAcousticAutoscroll() { - const newValue = !this.enableAcousticAutoscroll(); - this.enableAcousticAutoscroll.set(newValue); - localStorage.setItem('enable-acoustic-autoscroll', newValue.toString()); + toggleVisualAutoscroll() { + const newValue = !this.enableVisualAutoscroll(); + this.enableVisualAutoscroll.set(newValue); + localStorage.setItem('enable-visual-autoscroll', newValue.toString()); } toggleKaraokePageScrollMode() { diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts index 573b5bd..b5356f7 100644 --- a/src/app/services/theme.service.ts +++ b/src/app/services/theme.service.ts @@ -6,6 +6,9 @@ import { Injectable, signal, effect } from '@angular/core'; export class ThemeService { public highContrast = signal(true); + /** Whether the system prefers dark mode */ + private systemPrefersDark = signal(false); + constructor() { // Load from localStorage const saved = localStorage.getItem('high-contrast'); @@ -15,18 +18,49 @@ export class ThemeService { this.highContrast.set(true); } - // Effect to apply class to body + // Detect system dark mode preference + if (typeof window !== 'undefined') { + const darkMq = window.matchMedia('(prefers-color-scheme: dark)'); + this.systemPrefersDark.set(darkMq.matches); + darkMq.addEventListener('change', (e) => { + this.systemPrefersDark.set(e.matches); + }); + } + + // Effect to apply high-contrast class to body and html effect(() => { const isHigh = this.highContrast(); - if (typeof document !== 'undefined' && document.body) { + if (typeof document !== 'undefined') { + const root = document.documentElement; if (isHigh) { - document.body.classList.add('high-contrast'); + root.classList.add('high-contrast'); + if (document.body) document.body.classList.add('high-contrast'); } else { - document.body.classList.remove('high-contrast'); + root.classList.remove('high-contrast'); + if (document.body) document.body.classList.remove('high-contrast'); } } localStorage.setItem('high-contrast', isHigh.toString()); }); + + // Effect to manage Ionic dark palette class + // When high contrast is ON → NEVER apply dark palette (force light mode) + // When high contrast is OFF and system prefers dark → apply dark palette + effect(() => { + const isHigh = this.highContrast(); + const systemDark = this.systemPrefersDark(); + + if (typeof document !== 'undefined') { + const root = document.documentElement; + if (!isHigh && systemDark) { + root.classList.add('ion-palette-dark'); + if (document.body) document.body.classList.add('ion-palette-dark'); + } else { + root.classList.remove('ion-palette-dark'); + if (document.body) document.body.classList.remove('ion-palette-dark'); + } + } + }); } toggleContrast() { diff --git a/src/app/version.ts b/src/app/version.ts index 365f0ce..ee0bf97 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.06.04.1906'; +export const VERSION = '2026.06.06.0038'; diff --git a/src/global.scss b/src/global.scss index 4db99d2..bdd8598 100644 --- a/src/global.scss +++ b/src/global.scss @@ -33,8 +33,8 @@ */ /* @import "@ionic/angular/css/palettes/dark.always.css"; */ -/* @import "@ionic/angular/css/palettes/dark.class.css"; */ -@import "@ionic/angular/css/palettes/dark.system.css"; +/* @import "@ionic/angular/css/palettes/dark.system.css"; */ +@import "@ionic/angular/css/palettes/dark.class.css"; ion-header { border: none !important; @@ -66,14 +66,124 @@ ion-app { } /* High Contrast Mode Overrides */ +html.high-contrast, body.high-contrast { + color-scheme: light !important; +} + body.high-contrast { --ion-background-color: #ffffff; --ion-background-color-rgb: 255, 255, 255; --ion-text-color: #000000; --ion-text-color-rgb: 0, 0, 0; + /* Primary color - complete set for shadow DOM components */ --ion-color-primary: #000000; - --ion-color-secondary: #e67e22; // A bit darker for readability on white + --ion-color-primary-rgb: 0, 0, 0; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255, 255, 255; + --ion-color-primary-shade: #000000; + --ion-color-primary-tint: #1a1a1a; + + /* Secondary color - complete set for shadow DOM components */ + --ion-color-secondary: #e67e22; + --ion-color-secondary-rgb: 230, 126, 34; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #cb6f1e; + --ion-color-secondary-tint: #e98b38; + + /* Medium color - used by filter chips */ + --ion-color-medium: #92949c; + --ion-color-medium-rgb: 146, 148, 156; + --ion-color-medium-contrast: #ffffff; + --ion-color-medium-contrast-rgb: 255, 255, 255; + --ion-color-medium-shade: #808289; + --ion-color-medium-tint: #9d9fa6; + + /* Light color */ + --ion-color-light: #f4f5f8; + --ion-color-light-rgb: 244, 245, 248; + --ion-color-light-contrast: #000000; + --ion-color-light-contrast-rgb: 0, 0, 0; + --ion-color-light-shade: #d7d8da; + --ion-color-light-tint: #f5f6f9; + + /* Dark color */ + --ion-color-dark: #222428; + --ion-color-dark-rgb: 34, 36, 40; + --ion-color-dark-contrast: #ffffff; + --ion-color-dark-contrast-rgb: 255, 255, 255; + --ion-color-dark-shade: #1e2023; + --ion-color-dark-tint: #383a3e; + + /* Light mode background step variables (light → dark) */ + --ion-background-color-step-50: #f2f2f2; + --ion-background-color-step-100: #e6e6e6; + --ion-background-color-step-150: #d9d9d9; + --ion-background-color-step-200: #cccccc; + --ion-background-color-step-250: #bfbfbf; + --ion-background-color-step-300: #b3b3b3; + --ion-background-color-step-350: #a6a6a6; + --ion-background-color-step-400: #999999; + --ion-background-color-step-450: #8c8c8c; + --ion-background-color-step-500: #808080; + --ion-background-color-step-550: #737373; + --ion-background-color-step-600: #666666; + --ion-background-color-step-650: #595959; + --ion-background-color-step-700: #4d4d4d; + --ion-background-color-step-750: #404040; + --ion-background-color-step-800: #333333; + --ion-background-color-step-850: #262626; + --ion-background-color-step-900: #1a1a1a; + --ion-background-color-step-950: #0d0d0d; + + /* Light mode text step variables (dark → light) */ + --ion-text-color-step-50: #0d0d0d; + --ion-text-color-step-100: #1a1a1a; + --ion-text-color-step-150: #262626; + --ion-text-color-step-200: #333333; + --ion-text-color-step-250: #404040; + --ion-text-color-step-300: #4d4d4d; + --ion-text-color-step-350: #595959; + --ion-text-color-step-400: #666666; + --ion-text-color-step-450: #737373; + --ion-text-color-step-500: #808080; + --ion-text-color-step-550: #8c8c8c; + --ion-text-color-step-600: #999999; + --ion-text-color-step-650: #a6a6a6; + --ion-text-color-step-700: #b3b3b3; + --ion-text-color-step-750: #bfbfbf; + --ion-text-color-step-800: #cccccc; + --ion-text-color-step-850: #d9d9d9; + --ion-text-color-step-900: #e6e6e6; + --ion-text-color-step-950: #f2f2f2; + + /* Legacy step variables (for older Ionic components) */ + --ion-color-step-50: #f4f5f8; + --ion-color-step-100: #e0e0e0; + --ion-color-step-150: #dcdcdc; + --ion-color-step-200: #cccccc; + --ion-color-step-250: #bfbfbf; + --ion-color-step-300: #b3b3b3; + --ion-color-step-350: #a6a6a6; + --ion-color-step-400: #999999; + --ion-color-step-450: #8c8c8c; + --ion-color-step-500: #808080; + --ion-color-step-550: #737373; + --ion-color-step-600: #666666; + --ion-color-step-650: #595959; + --ion-color-step-700: #4d4d4d; + --ion-color-step-750: #404040; + --ion-color-step-800: #333333; + --ion-color-step-850: #262626; + --ion-color-step-900: #191919; + --ion-color-step-950: #0d0d0d; + + /* Reset component-specific dark mode variables */ + --ion-item-background: #ffffff; + --ion-card-background: #ffffff; + --ion-toolbar-background: #ffffff; + --ion-tab-bar-background: #ffffff; .bg-gradient { background: #ffffff !important; @@ -263,6 +373,19 @@ body.high-contrast { color: #000000 !important; } } + + /* Prevent button color/background issues in active/focus/hover states in high contrast */ + ion-button { + --color-activated: var(--color) !important; + --color-focused: var(--color) !important; + --color-hover: var(--color) !important; + + &[fill="clear"], &[fill="outline"] { + --background-activated: rgba(0, 0, 0, 0.1) !important; + --background-focused: rgba(0, 0, 0, 0.08) !important; + --background-hover: rgba(0, 0, 0, 0.05) !important; + } + } } .offline-badge-header { diff --git a/src/index.html b/src/index.html index 4d16fda..670729e 100644 --- a/src/index.html +++ b/src/index.html @@ -34,7 +34,44 @@ - + +
+
+

Avvio in corso

+

Caricamento dell'applicazione...

+
+
+
+
0%
+
+
+ +