From af12a1f2da275d54d86757c555d766f2aa174d04 Mon Sep 17 00:00:00 2001 From: David Frassi Date: Mon, 18 May 2026 16:07:37 +0200 Subject: [PATCH] =?UTF-8?q?gestione=20comunit=C3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editor-visuale.md | 164 +++++++++++++++ src/app/home/home.page.html | 39 +++- src/app/home/home.page.scss | 2 +- src/app/home/home.page.ts | 238 +++++++++++++++++++++- src/app/pages/player/player.page.html | 11 +- src/app/pages/player/player.page.ts | 49 +++++ src/app/pages/playlist/playlist.page.html | 4 +- src/app/pages/playlist/playlist.page.ts | 9 + src/app/pages/settings/settings.page.html | 69 ++++--- src/app/pages/settings/settings.page.ts | 10 +- src/app/services/canti-letture.service.ts | 10 +- src/app/services/comunita.service.ts | 194 ++++++++++++++++++ src/app/services/settings.service.ts | 89 +++++++- src/app/services/stats.service.ts | 192 +++++++++++++++++ src/app/version.ts | 2 +- 15 files changed, 1021 insertions(+), 61 deletions(-) create mode 100644 editor-visuale.md create mode 100644 src/app/services/comunita.service.ts create mode 100644 src/app/services/stats.service.ts diff --git a/editor-visuale.md b/editor-visuale.md new file mode 100644 index 0000000..53f73ed --- /dev/null +++ b/editor-visuale.md @@ -0,0 +1,164 @@ +# Specifiche e Considerazioni di Design: Editor Visuale Intelligente + +Questo documento raccoglie l'analisi e le specifiche di design per l'implementazione del sistema di **Scansione Fotocamera & AI** per la digitalizzazione rapida dei canti nella PWA. +L'obiettivo è progettare un'esperienza utente a "sfregamento zero" (zero friction), robusta anche in ambienti ostili (es. chiese senza campo) e capace di gestire le imperfezioni storiche dei libretti parrocchiali. + +--- + +## 1. Architettura della Pipeline Visiva + +Il processo si basa su un'interazione fluida tra client (PWA), proxy server ed il motore multimodale di visione artificiale (Google Gemini 2.5 Flash). + +```mermaid +graph TD + A[Inquadratura Fotocamera PWA] --> B[Compressione Client Canvas JPEG ~200KB] + B --> C{Connessione Rete?} + C -- Online --> D[Invia POST a /scanSong] + C -- Offline --> E[Salva in IndexedDB localmente] + E --> F[In attesa di Connessione...] + F --> C + D --> G[Chiamata a Gemini 2.5 Flash] + G --> H[Analisi Spaziale ed Estrazione JSON] + H --> I[Popolamento Automatico dell'Editor PWA] +``` + +--- + +## 2. Casi d'Uso Avanzati e Soluzioni Specifiche + +### A. Gestione Multi-Pagina (Canti Lunghi) +* **Problema**: Molti canti liturgici (es. Salmi o canti d'ingresso lunghi) si sviluppano su due pagine adiacenti o su foglietti fronte-retro separati. +* **Soluzione**: L'interfaccia dell'editor consentirà all'utente di aggiungere più scatti all'interno della stessa sessione di scansione prima dell'invio. +* **Gestione AI**: Le immagini verranno inviate in blocco al modello di visione (sfruttando l'enorme finestra di contesto di Gemini). Il prompt istruirà l'IA a considerare le foto come sequenziali, unendo il testo in un unico flusso coerente senza duplicare il titolo ed ordinando le strofe in modo corretto. + +### B. Correzione dei Refusi Storici (Libretti Anni '90) +* **Problema**: I libretti parrocchiali stampati decenni fa o digitati a macchina presentano spesso refusi tipografici, lettere scambiate o scorrettezze sugli accenti (es. `perche'` al posto di `perché`, `piu'` per `più`, `Gesu'` per `Gesù`). +* **Soluzione**: Sfruttando la comprensione semantica del modello di linguaggio (LLM), l'IA non effettua una mera trascrizione pixel-per-pixel, ma analizza il senso delle parole nel contesto dei canti liturgici italiani (molti dei quali fanno già parte della sua conoscenza pregressa). +* **Risultato**: Restituzione di un testo grammaticalmente perfetto e pulito, correggendo automaticamente i vecchi accenti e gli errori di battitura della carta originale. + +### C. Traduzione ed Uniformazione degli Accordi +* **Problema**: Gli spartiti o canzonieri cartacei possono utilizzare la notazione internazionale (`C, D, E, F, G, A, B`) anziché la notazione latina standard italiana (`Do, Re, Mi, Fa, Sol, La, Si`). +* **Soluzione**: Nel prompt di sistema, l'IA viene istruita ad uniformare tutti gli accordi alla notazione italiana standard. +* **Esempio**: Se l'utente inquadra un canzonierino scout o internazionale scritto in notazione inglese (es. `[C] [G] [Am]`), il sistema lo tradurrà istantaneamente in `[Do] [Sol] [Lam]` prima di inviarlo all'editor della PWA, garantendo la compatibilità al 100% con il modulo di trasposizione interna dei toni. + +--- + +## 3. Strategia Offline (Resilienza in Chiesa) + +Le chiese sono notoriamente ambienti difficili per la connettività di rete (mura in pietra spesse, sotterranei, assenza di ripetitori vicini). Il sistema non deve dipendere da una connessione sempre attiva. + +### Flusso Offline con IndexedDB +1. L'utente scatta la foto (o seleziona l'immagine) dall'editor mentre si trova offline. +2. L'app converte l'immagine in `Blob` e la salva localmente all'interno di **IndexedDB** associandola a una coda di scansioni pendenti (`scan-offline-queue`). +3. L'interfaccia mostra un avviso rassicurante: + > 💾 *Fogli acquisiti offline! L'app elaborerà automaticamente la scansione del canto non appena rileverai una connessione internet stabile.* +4. Il `ConnectivityService` della PWA monitora lo stato della rete. Non appena il dispositivo torna online (es. all'uscita dalla chiesa o sotto WiFi): + * L'app estrae l'immagine da IndexedDB. + * Effettua la chiamata POST a `/scanSong`. + * Riceve il JSON tradotto e notifica l'utente (o precompila la scheda dei canti proposti). + +--- + +## 4. Specifiche del Prompt di Sistema (System Prompt) + +L'output del backend deve essere rigidamente controllato e tipizzato per consentire al client Angular di digerirlo senza eccezioni di parsing. Il prompt di sistema da implementare sul proxy API sarà il seguente: + +```text +Sei un OCR musicale multimodale specializzato in canti liturgici e religiosi cristiani. +Analizza l'immagine fornita (o il set di immagini sequenziali) e restituisci ESCLUSIVAMENTE un oggetto JSON valido. Non aggiungere spiegazioni o markdown. + +Schema JSON di risposta: +{ + "titolo": "String (titolo del canto)", + "autore": "String o null", + "sezioni": [ + { + "tipo": "ritornello" | "strofa", + "numero": Number o null (solo per le strofe, es. 1, 2, 3), + "righe": [ + "Testo completo della riga con accordi musicali inseriti in linea tra parentesi quadre prima della sillaba corretta. Es: [Do]Tu sei la mia [Sol]vita" + ] + } + ] +} + +Regole di formattazione: +1. Traduci tutti gli accordi in notazione italiana (Do, Re, Mi, Fa, Sol, La, Si, con 'm' per i minori, '7' per le settime, ecc.). +2. Inserisci gli accordi esattamente nel punto in cui cadono spazialmente sopra le parole dell'immagine. +3. Correggi accenti storici errati (es. trasformando 'perche'' in 'perché') e refusi evidenti. +``` + +Questo approccio garantisce che la PWA riceva un formato perfettamente strutturato e pronto per essere inserito direttamente nelle righe del database locale. + +--- + +## 5. Approccio 100% Locale e Gratuito (Client-Side) + +Se il vincolo fondamentale è **non utilizzare alcun servizio cloud a pagamento o esterno**, possiamo spostare l'intero carico di elaborazione (OCR ed allineamento) **direttamente all'interno del browser dell'utente (client-side)** in modo 100% gratuito ed autonomo. + +Ciò è realizzabile integrando due componenti open-source nella PWA: + +1. **Tesseract.js** (compilato in WebAssembly per il browser): si occupa di estrarre il testo e, soprattutto, di fornirci i metadati spaziali di ogni singola parola. +2. **Algoritmo Euristico di Allineamento Spaziale**: un algoritmo scritto da noi in TypeScript che mappa la posizione fisica degli accordi rispetto al testo. + +### Come funziona l'algoritmo di allineamento locale? + +Quando Tesseract.js scansiona l'immagine, non restituisce solo una stringa, ma un array di parole in cui ogni parola contiene il testo ed il suo **Bounding Box (rettangolo di coordinate fisiche sull'immagine: `x0`, `y0`, `x1`, `y1`)**: + +```typescript +interface OCRWord { + text: string; + bbox: { x0: number; y0: number; x1: number; y1: number }; +} +``` + +L'algoritmo elabora questi dati in 4 fasi: + +#### Fase 1: Raggruppamento in Righe Fisiche +Le parole vengono ordinate per coordinata `y0`. Le parole che hanno valori di `y0` simili (con una tolleranza basata sull'altezza dei caratteri, es. ±15 pixel) vengono raggruppate nella stessa **riga orizzontale**. + +#### Fase 2: Classificazione delle Righe (Accordi vs. Testo) +Ogni riga viene analizzata per determinare la sua natura: +* **Riga di Accordi**: Se più del 70% delle parole nella riga corrisponde a pattern di accordi noti (es. parole corte come `Do`, `Sol`, `Re7`, `Lam`, `C`, `G`, `D7`), la riga viene marcata come riga di accordi. +* **Riga di Testo**: In tutti gli altri casi, viene considerata una riga di testo normale. + +#### Fase 3: Mappatura e Iniezione degli Accordi +Per ogni **Riga di Accordi**, individuiamo la **Riga di Testo** immediatamente sottostante (quella con coordinata `y` successiva più vicina). +Per ciascun accordo presente nella riga: +1. Calcoliamo la sua coordinata orizzontale centrale: `x_accordo = (bbox.x0 + bbox.x1) / 2`. +2. Scorriamo le parole della riga di testo sottostante per trovare quale parola (o lettera) ha la coordinata `x` più vicina a `x_accordo`. +3. Inseriamo la stringa dell'accordo tra parentesi quadre (es. `[Re]`) esattamente prima di quella parola nel testo finale. + +```text +Spaziatura fisica sull'immagine: +Riga 1 (Accordi): [Do] [Sol] +Riga 2 (Testo): Tu sei la mia vita, altro io non ho. + ↑ ↑ + x=120 x=480 +``` + +#### Fase 4: Segmentazione Strutturale delle Sezioni +L'algoritmo identifica i cambi di sezione (es. il passaggio da Ritornello a Strofa) misurando lo spazio verticale (`gap y`) tra le righe di testo. Un distanziamento verticale significativamente maggiore della media indica una separazione di strofa, consentendo di dividere il canto in blocchi strutturati. + +### Pro e Contro dell'Approccio Locale + +| Vantaggi | Svantaggi | +| :--- | :--- | +| 🟢 **100% Gratuito**: Nessun costo di licenza o API esterne. | 🔴 **Uso Risorse**: L'elaborazione OCR avviene sul telefono dell'utente (richiede 3-5 secondi). | +| 🟢 **Totale Privacy**: Le immagini non lasciano mai il dispositivo. | 🔴 **Meno tolleranza alle pieghe**: Rispetto a Gemini, un OCR locale soffre di più se la foto è storta o con ombre. | +| 🟢 **Funzionamento Offline**: Funziona anche nel deserto, senza alcuna rete. | 🔴 **Nessuna correzione lessicale**: Non corregge gli errori grammaticali storici del foglio cartaceo. | + +--- + +## 6. Il Compromesso Ideale: Gemini API Free Tier + +Esiste una terza via che unisce il meglio dei due mondi (la precisione assoluta dell'AI e il costo zero): **il Free Tier delle API di Google Gemini 2.5 Flash**. + +Google offre tramite **Google AI Studio** un piano d'uso completamente gratuito con i seguenti limiti: +* **15 Richieste al minuto (RPM)** +* **1500 Richieste al giorno (RPD)** + +Per un'applicazione di canti religiosi dove l'aggiunta di nuovi brani è un'operazione sporadica (eseguita da pochi amministratori/chitarristi solo quando si impara un canto nuovo), **questi limiti gratuiti non verranno mai superati**. + +* **Implementazione sicura**: Per evitare di inserire la chiave API gratuita all'interno del codice frontend (dove chiunque potrebbe rubarla), si fa passare la richiesta attraverso una semplice funzione serverless gratuita (es. su *Vercel Free Tier* o *Cloudflare Workers*), che funge da proxy sicuro e vi applica la chiave API prima di chiamare Google. + diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 8ac9fec..240e963 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -43,6 +43,27 @@ {{ filteredCanti().length }}
+
+ + + {{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }} + + +
+ +
+
+ - +
@@ -194,7 +217,17 @@ SINTESI MESSA {{ getSelectedMassFormattedDate() }}
- +
+
+ + + + + + +
+ +
@@ -234,7 +267,7 @@

- {{ canto.titolo }} + {{ getCommunitySongNumber(canto) }}{{ canto.titolo }}

{{ canto.autore || 'Autore sconosciuto' }} diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 31062d0..1950879 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -66,7 +66,7 @@ width: 100%; overflow-x: auto; gap: 12px; - padding: 4px 0; + padding: 8px 4px 8px 0; // Hide scrollbar but keep functionality &::-webkit-scrollbar { diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 9be0c68..195d273 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -9,11 +9,12 @@ import { PlaylistService } from '../services/playlist.service'; import { SettingsService } from '../services/settings.service'; import { VERSION } from '../version'; import { YoutubePlayerService } from '../services/youtube-player.service'; -import { ModalController, AlertController, ToastController } from '@ionic/angular'; +import { ModalController, AlertController, ToastController, LoadingController } from '@ionic/angular'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { MyCantiService } from '../services/my-canti.service'; import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component'; import { CantiLettureService } from '../services/canti-letture.service'; +import { ComunitaService } from '../services/comunita.service'; @Component({ selector: 'app-home', @@ -38,6 +39,7 @@ export class HomePage implements OnDestroy { public fontSize = signal(1.0); public youtubePlayerService = inject(YoutubePlayerService); + public comunitaService = inject(ComunitaService); public isSeeking = false; private readonly MIN_FONT = 0.6; @@ -59,6 +61,7 @@ export class HomePage implements OnDestroy { private modalCtrl = inject(ModalController); private alertCtrl = inject(AlertController); private toastCtrl = inject(ToastController); + private loadingCtrl = inject(LoadingController); private firstInteraction = true; @@ -70,7 +73,14 @@ export class HomePage implements OnDestroy { private scrollEffect = effect(() => { const activeId = this.youtubePlayerService.currentCantoId(); + const list = this.filteredCanti(); + if (activeId) { + const index = list.findIndex(c => c.id === activeId); + if (index !== -1 && index >= this.limit()) { + this.limit.set(index + 15); + } + // Delay slightly to ensure DOM is updated and classes are applied setTimeout(() => { const element = document.getElementById('canto-' + activeId); @@ -79,7 +89,7 @@ export class HomePage implements OnDestroy { } }, 500); } - }); + }, { allowSignalWrites: true }); private normalize(str: string | undefined): string { if (!str) return ''; @@ -103,6 +113,25 @@ export class HomePage implements OnDestroy { let list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()]; + // Filter by Community if code is set and filter is active + const comunitaCode = this.comunitaService.comunitaCode(); + const comunitaIds = this.comunitaService.comunitaCantiIds(); + if (comunitaCode && this.comunitaService.isFilterActive()) { + list = list.filter(c => { + return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id); + }); + + // Sort list by community song number (num_canto) ASC! + const cantiInfo = this.comunitaService.comunitaCantiInfo(); + list.sort((a, b) => { + const infoA = cantiInfo.find(x => x.id_canti === a.id_canti || x.id_canti === Number(a.id)); + const infoB = cantiInfo.find(x => x.id_canti === b.id_canti || x.id_canti === Number(b.id)); + const numA = infoA ? Number(infoA.num_canto) : 999999; + const numB = infoB ? Number(infoB.num_canto) : 999999; + return numA - numB; + }); + } + if (onlyMine) { list = this.myCantiService.myCanti(); } @@ -160,17 +189,21 @@ export class HomePage implements OnDestroy { // Converte "uno" in "1" per facilitare la ricerca vocale (es. "numero uno") const processedQuery = query.replace(/\buno\b/g, '1'); - // Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti + // Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti/comunita number const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/); if (numberMatchPattern) { const targetId = numberMatchPattern[1]; - return list.filter(c => c.id_canti.toString() === targetId); + return list.filter(c => { + const commNum = this.getCommunitySongNumber(c); + return commNum ? commNum === targetId : c.id_canti.toString() === targetId; + }); } return list.filter(c => { const titleMatch = this.normalize(c.titolo).includes(query); const authorMatch = this.normalize(c.autore).includes(query); - const numberMatch = c.id_canti.toString().includes(query); + const commNum = this.getCommunitySongNumber(c); + const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query); // Strip tags like {Chorus} and newlines from lyrics before searching const lyricsPlain = (c.testo || '') @@ -642,10 +675,6 @@ export class HomePage implements OnDestroy { setTimeout(() => { this.limit.update(l => l + 10); event.target.complete(); - - if (this.limit() >= this.filteredCanti().length) { - event.target.disabled = true; - } }, 500); } @@ -883,4 +912,195 @@ export class HomePage implements OnDestroy { this.limit.set(10); } + getCommunitySongNumber(canto: any): string | null { + if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null; + const cantiInfo = this.comunitaService.comunitaCantiInfo(); + const info = cantiInfo.find(x => x.id_canti === canto.id_canti || x.id_canti === Number(canto.id)); + return info && info.num_canto ? info.num_canto.toString() : null; + } + + toggleComunitaFilter() { + if (!this.comunitaService.comunitaCode()) { + this.promptComunitaCode(); + } else { + this.comunitaService.isFilterActive.update(v => !v); + } + } + + editComunitaCode(event: Event) { + event.stopPropagation(); + this.promptComunitaCode(); + } + + async promptComunitaCode() { + const alert = await this.alertCtrl.create({ + header: 'Imposta Comunità', + subHeader: 'Inserisci il codice parrocchiale/comunità per attivare il libretto dedicato:', + cssClass: 'premium-alert', + inputs: [ + { + name: 'code', + type: 'text', + placeholder: 'Es: 123456', + value: this.comunitaService.comunitaCode() + } + ], + buttons: [ + { + text: 'Annulla', + role: 'cancel' + }, + { + text: 'Rimuovi', + role: 'destructive', + cssClass: 'alert-button-delete', + handler: async () => { + await this.comunitaService.setComunitaCode(''); + const toast = await this.toastCtrl.create({ + message: 'Comunità disattivata.', + duration: 2000, + color: 'secondary' + }); + await toast.present(); + } + }, + { + text: 'Salva', + handler: async (data) => { + const trimmed = (data.code || '').trim(); + if (!trimmed) { + await this.comunitaService.setComunitaCode(''); + return; + } + + // Show loading overlay with progress + const loading = await this.loadingCtrl.create({ + message: 'Caricamento 0%', + cssClass: 'premium-loading', + spinner: 'crescent' + }); + await loading.present(); + + // Subscribe to progress updates + let progressInterval: any = null; + progressInterval = setInterval(() => { + const pct = this.comunitaService.loadingProgress(); + loading.message = `Caricamento ${pct}%`; + if (pct >= 100) { + clearInterval(progressInterval); + } + }, 100); + + const success = await this.comunitaService.setComunitaCode(trimmed); + clearInterval(progressInterval); + await loading.dismiss(); + + if (success) { + const toast = await this.toastCtrl.create({ + message: `Comunità attivata: ${this.comunitaService.comunitaNome()}`, + duration: 2000, + color: 'success' + }); + await toast.present(); + } else { + const toast = await this.toastCtrl.create({ + message: 'Codice non trovato o errore di connessione.', + duration: 2000, + color: 'danger' + }); + await toast.present(); + setTimeout(() => this.promptComunitaCode(), 500); + } + } + } + ] + }); + await alert.present(); + } + + private massCardStartX: number = 0; + private massCardStartY: number = 0; + + onMassCardTouchStart(event: TouchEvent) { + if (event.touches.length === 1) { + this.massCardStartX = event.touches[0].clientX; + this.massCardStartY = event.touches[0].clientY; + } + } + + onMassCardTouchEnd(event: TouchEvent) { + if (event.changedTouches.length === 1) { + const endX = event.changedTouches[0].clientX; + const endY = event.changedTouches[0].clientY; + const diffX = endX - this.massCardStartX; + const diffY = endY - this.massCardStartY; + + // Ensure it is a horizontal swipe (diffX is much larger than diffY) + if (Math.abs(diffX) > 60 && Math.abs(diffY) < 40) { + // Prevent triggering click expansion + event.stopPropagation(); + event.preventDefault(); + + if (diffX > 0) { + // Swiped right -> go to PREVIOUS date + this.navigateMassDate(-1); + } else { + // Swiped left -> go to NEXT date + this.navigateMassDate(1); + } + } + } + } + + async navigateMassDate(direction: number) { + const list = this.cantiLettureService.availableMasses(); + if (list.length === 0) return; + + const currentDate = this.cantiLettureService.selectedMassDate(); + if (!currentDate) { + this.cantiLettureService.selectedMassDate.set(list[0].date); + return; + } + + const currentIndex = list.findIndex(m => m.date === currentDate); + if (currentIndex === -1) { + this.cantiLettureService.selectedMassDate.set(list[0].date); + return; + } + + const nextIndex = currentIndex + direction; + if (nextIndex >= 0 && nextIndex < list.length) { + // Set the next/prev date + this.cantiLettureService.selectedMassDate.set(list[nextIndex].date); + + const toast = await this.toastCtrl.create({ + message: `Messa: ${list[nextIndex].day} ${this.formatCompactDate(list[nextIndex].date)}`, + duration: 1500, + color: 'secondary', + position: 'bottom' + }); + await toast.present(); + } else { + // "quando le messe sono finite rimani sull'ultima valorizzata, non dare errore" + const targetIndex = direction > 0 ? list.length - 1 : 0; + this.cantiLettureService.selectedMassDate.set(list[targetIndex].date); + + const toast = await this.toastCtrl.create({ + message: direction > 0 ? 'Nessun\'altra messa futura disponibile.' : 'Nessun\'altra messa passata disponibile.', + duration: 1500, + color: 'medium', + position: 'bottom' + }); + await toast.present(); + } + } + + private formatCompactDate(dateStr: string): string { + const parts = dateStr.split('-'); + if (parts.length === 3) { + return `${parts[2]}/${parts[1]}`; + } + return dateStr; + } } + diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index add023d..65371f9 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -4,10 +4,19 @@

+ + + +
- {{ canto()?.id_canti }} + + {{ getCommunitySongNumber(canto()) || canto()?.id_canti }} + {{ canto()?.titolo || 'Player' }}
diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index 4e271c5..b4556f0 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -11,6 +11,8 @@ import { ConnectivityService } from '../../services/connectivity.service'; import { PlaylistService } from '../../services/playlist.service'; import { YoutubePlayerService } from '../../services/youtube-player.service'; import { MyCantiService } from '../../services/my-canti.service'; +import { ComunitaService } from '../../services/comunita.service'; +import { StatsService } from '../../services/stats.service'; @Component({ selector: 'app-player', @@ -103,11 +105,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public settingsService = inject(SettingsService); public connectivityService = inject(ConnectivityService); public playlistService = inject(PlaylistService); + public comunitaService = inject(ComunitaService); private myCantiService = inject(MyCantiService); + private statsService = inject(StatsService); private gestureCtrl = inject(GestureController); private el = inject(ElementRef); private sanitizer = inject(DomSanitizer); + private songStartTime: number = 0; + constructor() { // Sync mode from global settings effect(() => { @@ -195,9 +201,24 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } if (found) { + this.logPreviousSongTime(); this.canto.set(found); + this.songStartTime = Date.now(); this.cantiService.getStorage()?.set('last_song_id', id); this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id }); + + // Set custom community transposition if active + if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) { + const settings = this.comunitaService.comunitaCantiSettings(); + const songSetting = settings.find(s => s.id_canti === found.id_canti || s.id_canti === Number(found.id)); + if (songSetting && songSetting.tonalita !== undefined) { + this.transposeAmount.set(songSetting.tonalita); + } else { + this.transposeAmount.set(0); + } + } else { + this.transposeAmount.set(0); + } } } }); @@ -243,11 +264,21 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { transposeUp() { this.transposeAmount.update(v => v + 1); this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount: this.transposeAmount() }); + + const c = this.canto(); + if (c) { + this.statsService.updateSongSettings(c.id_canti, this.transposeAmount()); + } } transposeDown() { this.transposeAmount.update(v => v - 1); this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount: this.transposeAmount() }); + + const c = this.canto(); + if (c) { + this.statsService.updateSongSettings(c.id_canti, this.transposeAmount()); + } } zoomIn() { @@ -485,7 +516,25 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + getCommunitySongNumber(canto: any): string | null { + if (!canto) return null; + if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null; + const cantiInfo = this.comunitaService.comunitaCantiInfo(); + const info = cantiInfo.find(x => x.id_canti === canto.id_canti || x.id_canti === Number(canto.id)); + return info && info.num_canto ? info.num_canto.toString() : null; + } + + private logPreviousSongTime() { + const c = this.canto(); + if (c && this.songStartTime > 0) { + const timeSpent = (Date.now() - this.songStartTime) / 1000; + this.statsService.logSongView(c.id_canti, timeSpent); + this.songStartTime = 0; + } + } + ngOnDestroy() { + this.logPreviousSongTime(); this.audioEngine.stopListening(); this.channel.close(); } diff --git a/src/app/pages/playlist/playlist.page.html b/src/app/pages/playlist/playlist.page.html index 9b4b77e..521d3bc 100644 --- a/src/app/pages/playlist/playlist.page.html +++ b/src/app/pages/playlist/playlist.page.html @@ -25,7 +25,9 @@
{{ song.id_canti }} - {{ song.titolo }} + + {{ getCommunitySongNumber(song) }}{{ song.titolo }} +
diff --git a/src/app/pages/playlist/playlist.page.ts b/src/app/pages/playlist/playlist.page.ts index 457c608..9b0c2a2 100644 --- a/src/app/pages/playlist/playlist.page.ts +++ b/src/app/pages/playlist/playlist.page.ts @@ -1,6 +1,7 @@ import { Component, inject, computed } from '@angular/core'; import { PlaylistService } from '../../services/playlist.service'; import { CantiService } from '../../services/canti.service'; +import { ComunitaService } from '../../services/comunita.service'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { AlertController, ToastController, ModalController } from '@ionic/angular'; import { Router } from '@angular/router'; @@ -14,6 +15,7 @@ import { Router } from '@angular/router'; export class PlaylistPage { public playlistService = inject(PlaylistService); public cantiService = inject(CantiService); + public comunitaService = inject(ComunitaService); private alertCtrl = inject(AlertController); private toastCtrl = inject(ToastController); private router = inject(Router); @@ -177,4 +179,11 @@ export class PlaylistPage { }); await toast.present(); } + + getCommunitySongNumber(song: any): string | null { + if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null; + const cantiInfo = this.comunitaService.comunitaCantiInfo(); + 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; + } } diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index 451c9b0..5540b20 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -61,37 +61,31 @@
- +
-
-

- Messa di Riferimento (Suggeriti) -

-
- -
-
- -
-

Seleziona Messa

-

Scegli la messa per i canti consigliati

-
-
- -
- - - {{ mass.formattedLabel }} - - -
-
+ + + +

Comunità

+

Mostra il filtro Comunità nella home

+
+ +
+ +
+ + + +

Invio dati statistici

+

Invia statistiche di utilizzo e tonalità

+
+ +
+
+ +
@@ -117,6 +111,8 @@
+ +
@@ -185,6 +181,23 @@
+
+
+
+ +
+
+ +
+
+
+

Testo / Accordi (icona in alto a destra)

+

+ Alterna la visualizzazione tra solo testo e testo con accordi. L'icona con la T (grigia) indica la modalità testo; l'icona con le note musicali (arancione) indica la modalità accordi. Un tocco cambia modalità all'istante. +

+
+
+
diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index c8fb749..0b52a03 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -11,6 +11,7 @@ import { Router } from '@angular/router'; import { MyCantiService } from '../../services/my-canti.service'; import { CantiLettureService } from '../../services/canti-letture.service'; +import { ComunitaService } from '../../services/comunita.service'; @Component({ selector: 'app-settings', @@ -26,6 +27,7 @@ export class SettingsPage { public connectivityService = inject(ConnectivityService); public playlistService = inject(PlaylistService); public cantiLettureService = inject(CantiLettureService); + public comunitaService = inject(ComunitaService); private swUpdate = inject(SwUpdate); private toastCtrl = inject(ToastController); private modalCtrl = inject(ModalController); @@ -47,6 +49,13 @@ export class SettingsPage { async fullRefresh() { // 1. Refresh JSON data this.cantiService.refresh(); + + // 2. Refresh liturgical readings JSON + try { + await this.cantiLettureService.fetchData(); + } catch (err) { + console.error('Failed to refresh liturgical readings:', err); + } // 2. Check for Service Worker updates if (this.swUpdate.isEnabled) { @@ -77,5 +86,4 @@ export class SettingsPage { }); await toast.present(); } - } diff --git a/src/app/services/canti-letture.service.ts b/src/app/services/canti-letture.service.ts index 6a46573..2e90d25 100644 --- a/src/app/services/canti-letture.service.ts +++ b/src/app/services/canti-letture.service.ts @@ -180,20 +180,20 @@ export class CantiLettureService { this.availableMasses.set(massesList); - // If no mass is currently selected, select the first one (or today's mass if exists) - if (!this.selectedMassDate() && massesList.length > 0) { - // Try to find today's date + // Always select today's mass if available on load, else find closest future date + if (massesList.length > 0) { const todayStr = new Date().toISOString().split('T')[0]; const match = massesList.find(m => m.date === todayStr); if (match) { this.selectedMassDate.set(match.date); } else { - // Find the first date that is today or in the future + // If today is not present, find the first date that is in the future const futureMatch = massesList.find(m => m.date >= todayStr); if (futureMatch) { this.selectedMassDate.set(futureMatch.date); } else { - this.selectedMassDate.set(massesList[0].date); + // Otherwise fall back to the last available mass date (closest to today) + this.selectedMassDate.set(massesList[massesList.length - 1].date); } } } diff --git a/src/app/services/comunita.service.ts b/src/app/services/comunita.service.ts new file mode 100644 index 0000000..67b1bed --- /dev/null +++ b/src/app/services/comunita.service.ts @@ -0,0 +1,194 @@ +import { Injectable, signal, inject, effect } from '@angular/core'; +import { HttpClient, HttpRequest, HttpEventType, HttpResponse } from '@angular/common/http'; +import { SettingsService } from './settings.service'; + +export interface ParrocchiaItem { + id_parrocchia: number; + nome: string; + codice: string; + mail: string; + guid_parrocchia: string; +} + +export interface ParrocchiaCantiItem { + id_parrocchia: number; + id_canti: number; + num_canto: number; +} + +export interface CantiSettingsItem { + id_canti: number; + speed: number; + tonalita: number; +} + +export interface GetAllAppTablesResponse { + parrocchia?: { data: ParrocchiaItem[] }; + parrocchia_canti?: { data: ParrocchiaCantiItem[] }; + canti_settings?: { data: CantiSettingsItem[] }; +} + +@Injectable({ + providedIn: 'root' +}) +export class ComunitaService { + private http = inject(HttpClient); + private settingsService = inject(SettingsService); + + public comunitaCode = signal(''); + public comunitaNome = signal(''); + public comunitaMail = signal(''); + public comunitaCantiIds = signal<(number | string)[]>([]); + public comunitaCantiInfo = signal([]); + public comunitaCantiSettings = signal([]); + public isFilterActive = signal(false); + public loading = signal(false); + public loadingProgress = signal(0); + + constructor() { + const savedCode = localStorage.getItem('comunita-code'); + const savedNome = localStorage.getItem('comunita-nome'); + const savedMail = localStorage.getItem('comunita-mail'); + const savedCanti = localStorage.getItem('comunita-canti-ids'); + const savedInfo = localStorage.getItem('comunita-canti-info'); + const savedSettings = localStorage.getItem('comunita-canti-settings'); + const savedFilterActive = localStorage.getItem('comunita-filter-active') === 'true'; + + if (savedCode) this.comunitaCode.set(savedCode); + if (savedNome) this.comunitaNome.set(savedNome); + if (savedMail) this.comunitaMail.set(savedMail); + if (savedCanti) { + try { this.comunitaCantiIds.set(JSON.parse(savedCanti)); } catch (e) {} + } + if (savedInfo) { + try { this.comunitaCantiInfo.set(JSON.parse(savedInfo)); } catch (e) {} + } + if (savedSettings) { + try { this.comunitaCantiSettings.set(JSON.parse(savedSettings)); } catch (e) {} + } + this.isFilterActive.set(savedFilterActive); + + effect(() => { + localStorage.setItem('comunita-code', this.comunitaCode()); + localStorage.setItem('comunita-nome', this.comunitaNome()); + localStorage.setItem('comunita-mail', this.comunitaMail()); + localStorage.setItem('comunita-canti-ids', JSON.stringify(this.comunitaCantiIds())); + localStorage.setItem('comunita-canti-info', JSON.stringify(this.comunitaCantiInfo())); + localStorage.setItem('comunita-canti-settings', JSON.stringify(this.comunitaCantiSettings())); + localStorage.setItem('comunita-filter-active', this.isFilterActive().toString()); + }); + + effect(() => { + if (!this.settingsService.comunitaEnabled()) { + this.isFilterActive.set(false); + } + }); + } + + private fetchWithProgress(url: string): Promise { + return new Promise((resolve, reject) => { + const req = new HttpRequest('GET', url, { + reportProgress: true, + responseType: 'json' + }); + + this.http.request(req).subscribe({ + next: (event) => { + if (event.type === HttpEventType.DownloadProgress) { + if (event.total && event.total > 0) { + const pct = Math.round((event.loaded / event.total) * 100); + this.loadingProgress.set(pct); + } else { + // No Content-Length header — simulate gradual progress up to 85% + const simulated = Math.min(85, this.loadingProgress() + 12); + this.loadingProgress.set(simulated); + } + } else if (event instanceof HttpResponse) { + this.loadingProgress.set(100); + resolve(event.body as T); + } + }, + error: (err) => reject(err) + }); + }); + } + + async setComunitaCode(code: string): Promise { + const trimmedCode = code.trim(); + if (!trimmedCode) { + this.comunitaCode.set(''); + this.comunitaNome.set(''); + this.comunitaMail.set(''); + this.comunitaCantiIds.set([]); + this.comunitaCantiInfo.set([]); + this.comunitaCantiSettings.set([]); + this.isFilterActive.set(false); + return true; + } + + this.loading.set(true); + this.loadingProgress.set(0); + + // 1. Try to fetch from the actual production API + try { + const url = `https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=${trimmedCode}&all_song=false&t=${Date.now()}`; + const data = await this.fetchWithProgress(url); + + if (data && data.parrocchia?.data && data.parrocchia.data.length > 0) { + const parrocchia = data.parrocchia.data[0]; + const cantiList = data.parrocchia_canti?.data || []; + const settingsList = data.canti_settings?.data || []; + + this.comunitaCode.set(trimmedCode); + this.comunitaNome.set(parrocchia.nome || `Comunità ${trimmedCode}`); + this.comunitaMail.set(parrocchia.mail || ''); + this.comunitaCantiInfo.set(cantiList); + this.comunitaCantiIds.set(cantiList.map(c => c.id_canti)); + this.comunitaCantiSettings.set(settingsList); + this.isFilterActive.set(true); + this.loading.set(false); + return true; + } + } catch (err) { + console.warn('Failed to fetch from production API, trying fallback...', err); + this.loadingProgress.set(0); + } + + // 2. Fallback to static community JSON + try { + const isProduction = window.location.hostname.includes('canticristiani.it'); + const origin = isProduction ? window.location.origin : 'https://www.canticristiani.it'; + const fallbackUrl = `${origin}/api/comunita_${trimmedCode}.json?t=${Date.now()}`; + + interface StaticComunitaData { + id_comunita: string; + nome_comunita: string; + canti: (number | string)[]; + } + + const staticData = await this.fetchWithProgress(fallbackUrl); + if (staticData && staticData.canti) { + this.comunitaCode.set(trimmedCode); + this.comunitaNome.set(staticData.nome_comunita || `Comunità ${trimmedCode}`); + this.comunitaCantiIds.set(staticData.canti); + + const mockInfo = staticData.canti.map((id, index) => ({ + id_parrocchia: 1, + id_canti: Number(id), + num_canto: index + 1 + })); + + this.comunitaCantiInfo.set(mockInfo); + this.comunitaCantiSettings.set([]); + this.isFilterActive.set(true); + this.loading.set(false); + return true; + } + } catch (err) { + console.error('All community fetch strategies failed:', err); + } + + this.loading.set(false); + return false; + } +} diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 6dff6f3..746c713 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -11,7 +11,14 @@ export class SettingsService { public fullscreenMode = signal(false); /** Browser Fullscreen state: true = fullscreen active */ - public browserFullscreen = signal(!!document.fullscreenElement); + public browserFullscreen = signal( + !!( + document.fullscreenElement || + (document as any).webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement + ) + ); /** Avanzamento automatico: true = passa al brano successivo automaticamente */ public autoAdvance = signal(true); @@ -22,6 +29,12 @@ export class SettingsService { /** Schermo sempre acceso: true = attiva Screen Wake Lock */ public keepScreenOn = signal(false); + /** Funzionalità Comunità: true = il chip comunità è visibile nella home */ + public comunitaEnabled = signal(true); + + /** Invio dati statistici: true = invia pacchetto dati statistici */ + public invioDatiStatistici = signal(false); + private wakeLock: any = null; constructor() { @@ -53,10 +66,31 @@ export class SettingsService { this.keepScreenOn.set(savedKeepScreenOn === 'true'); } - // Sync browser fullscreen state with listeners - document.addEventListener('fullscreenchange', () => { - this.browserFullscreen.set(!!document.fullscreenElement); - }); + const savedComunitaEnabled = localStorage.getItem('comunita-enabled'); + if (savedComunitaEnabled !== null) { + this.comunitaEnabled.set(savedComunitaEnabled === 'true'); + } + + const savedStats = localStorage.getItem('invio-dati-statistici'); + if (savedStats !== null) { + this.invioDatiStatistici.set(savedStats === 'true'); + } + + // Sync browser fullscreen state with listeners (supporting vendor prefixes) + const updateFullscreenState = () => { + const isFs = !!( + document.fullscreenElement || + (document as any).webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement + ); + this.browserFullscreen.set(isFs); + }; + + document.addEventListener('fullscreenchange', updateFullscreenState); + document.addEventListener('webkitfullscreenchange', updateFullscreenState); + document.addEventListener('mozfullscreenchange', updateFullscreenState); + document.addEventListener('MSFullscreenChange', updateFullscreenState); effect(() => { localStorage.setItem('show-chords-default', this.showChordsDefault().toString()); @@ -110,6 +144,12 @@ export class SettingsService { this.keepScreenOn.update(v => !v); } + toggleComunitaEnabled() { + const newValue = !this.comunitaEnabled(); + this.comunitaEnabled.set(newValue); + localStorage.setItem('comunita-enabled', newValue.toString()); + } + private async requestWakeLock() { if ('wakeLock' in navigator) { try { @@ -128,14 +168,41 @@ export class SettingsService { } toggleBrowserFullscreen() { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen().catch(err => { - console.error(`Error attempting to enable full-screen mode: ${err.message}`); - }); + const isFs = !!( + document.fullscreenElement || + (document as any).webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement + ); + + if (!isFs) { + const docEl = document.documentElement as any; + if (docEl.requestFullscreen) { + docEl.requestFullscreen().catch((err: any) => console.error(err)); + } else if (docEl.webkitRequestFullscreen) { + docEl.webkitRequestFullscreen(); + } else if (docEl.mozRequestFullScreen) { + docEl.mozRequestFullScreen(); + } else if (docEl.msRequestFullscreen) { + docEl.msRequestFullscreen(); + } } else { - if (document.exitFullscreen) { - document.exitFullscreen(); + const doc = document as any; + if (doc.exitFullscreen) { + doc.exitFullscreen().catch((err: any) => console.error(err)); + } else if (doc.webkitExitFullscreen) { + doc.webkitExitFullscreen(); + } else if (doc.mozCancelFullScreen) { + doc.mozCancelFullScreen(); + } else if (doc.msExitFullscreen) { + doc.msExitFullscreen(); } } } + + toggleInvioDatiStatistici() { + const newValue = !this.invioDatiStatistici(); + this.invioDatiStatistici.set(newValue); + localStorage.setItem('invio-dati-statistici', newValue.toString()); + } } diff --git a/src/app/services/stats.service.ts b/src/app/services/stats.service.ts new file mode 100644 index 0000000..77fb3f7 --- /dev/null +++ b/src/app/services/stats.service.ts @@ -0,0 +1,192 @@ +import { Injectable, inject, effect } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { SettingsService } from './settings.service'; +import { ConnectivityService } from './connectivity.service'; +import { ComunitaService } from './comunita.service'; +import { VERSION } from '../version'; + +@Injectable({ + providedIn: 'root' +}) +export class StatsService { + private http = inject(HttpClient); + private settingsService = inject(SettingsService); + private connectivityService = inject(ConnectivityService); + private comunitaService = inject(ComunitaService); + + constructor() { + // Automatically trigger sync when the device becomes online + effect(() => { + if (this.connectivityService.isOnline()) { + this.syncStats(); + } + }); + } + + private getOrCreateUUID(): string { + let uuid = localStorage.getItem('stats-uuid'); + if (!uuid) { + uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + localStorage.setItem('stats-uuid', uuid); + } + return uuid; + } + + private formatDateTime(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + const y = date.getFullYear(); + const m = pad(date.getMonth() + 1); + const d = pad(date.getDate()); + const h = date.getHours(); + const min = pad(date.getMinutes()); + const s = pad(date.getSeconds()); + return `${y}-${m}-${d} ${h}:${min}:${s}`; + } + + private getBrowserModel(): string { + const ua = navigator.userAgent; + if (ua.includes('Chrome')) return 'Chrome'; + if (ua.includes('Safari') && !ua.includes('Chrome')) return 'Safari'; + if (ua.includes('Firefox')) return 'Firefox'; + return 'Browser'; + } + + private getLocalLogs(): any[] { + const data = localStorage.getItem('stats-logs-queue'); + return data ? JSON.parse(data) : []; + } + + private saveLocalLogs(logs: any[]) { + localStorage.setItem('stats-logs-queue', JSON.stringify(logs)); + } + + private clearLocalLogs() { + localStorage.removeItem('stats-logs-queue'); + } + + private getLocalSongSettings(): any[] { + const data = localStorage.getItem('stats-settings-queue'); + return data ? JSON.parse(data) : []; + } + + private saveLocalSongSettings(settings: any[]) { + localStorage.setItem('stats-settings-queue', JSON.stringify(settings)); + } + + logSongView(idCanti: number, timeSpentSeconds: number) { + if (!this.settingsService.invioDatiStatistici()) return; + if (timeSpentSeconds < 1) return; // Ignore very short views + + console.log('📊 StatsService: logSongView called for song', idCanti, 'timespent:', timeSpentSeconds); + + const logEntry = { + uuid: this.getOrCreateUUID(), + id_canti: idCanti.toString(), + data_log: this.formatDateTime(new Date()), + version: VERSION, + tempo: Math.round(timeSpentSeconds), + lat: null, + lon: null, + json: JSON.stringify({ + model: this.getBrowserModel(), + height: window.innerHeight, + width: window.innerWidth + }), + stato: 0, + id: Math.floor(Math.random() * 100000) + }; + + const logs = this.getLocalLogs(); + logs.push(logEntry); + this.saveLocalLogs(logs); + + this.syncStats(); + } + + updateSongSettings(idCanti: number, tonalita: number, speed: number = 0) { + if (!this.settingsService.invioDatiStatistici()) return; + + console.log('📊 StatsService: updateSongSettings called for song', idCanti, 'tonalita:', tonalita); + + const settingsList = this.getLocalSongSettings(); + const idx = settingsList.findIndex(s => s.id_canti === idCanti); + + if (idx !== -1) { + settingsList[idx].tonalita = tonalita; + settingsList[idx].speed = speed; + } else { + settingsList.push({ + id_canti: idCanti, + speed: speed, + tonalita: tonalita, + id: Math.floor(Math.random() * 100000) + }); + } + + this.saveLocalSongSettings(settingsList); + this.syncStats(); + } + + private clearLocalStats() { + localStorage.removeItem('stats-logs-queue'); + localStorage.removeItem('stats-settings-queue'); + } + + syncStats() { + if (!this.settingsService.invioDatiStatistici()) { + console.log('📊 StatsService: Sync ignored (settings disabled)'); + return; + } + if (!this.connectivityService.isOnline()) { + console.log('📊 StatsService: Sync postponed (offline)'); + return; + } + + const logs = this.getLocalLogs(); + const settings = this.getLocalSongSettings(); + + console.log('📊 StatsService: syncStats checking queues. Logs:', logs.length, 'Settings:', settings.length); + + if (logs.length === 0 && settings.length === 0) { + return; + } + + const uuid = this.getOrCreateUUID(); + const email = this.comunitaService.comunitaMail() || `pwa-${uuid.substring(0,8)}@librettocanti.it`; + + const payload = { + uuid: uuid, + log: logs, + pref: [], + canti_settings: settings, + user_devices: { + uuid: uuid, + email: email, + platform: 'browser', + version: VERSION, + gruppo: (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) ? this.comunitaService.comunitaCode() : '', + all_song: false, + deleteList: false, + deletePref: false, + data_app_sync: this.formatDateTime(new Date()) + } + }; + + const url = `https://api.librettocanti.it/canti/api/v1/updateOnlineDB?uuid=${uuid}&email=${email}`; + console.log('📊 StatsService: Sending POST request to:', url, 'with payload:', payload); + + this.http.post(url, payload).subscribe({ + next: (res) => { + this.clearLocalStats(); + console.log('📊 StatsService: Sync successful!', res); + }, + error: (err) => { + console.error('📊 StatsService: Sync failed, keeping in local buffer...', err); + } + }); + } +} diff --git a/src/app/version.ts b/src/app/version.ts index 850e327..cf70097 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.05.18.0125'; +export const VERSION = '2026.05.18.1550';