gestione comunità

This commit is contained in:
David Frassi
2026-05-18 16:07:37 +02:00
parent 961b7ba65c
commit af12a1f2da
15 changed files with 1021 additions and 61 deletions
+164
View File
@@ -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.
+36 -3
View File
@@ -43,6 +43,27 @@
{{ filteredCanti().length }}
</div>
<div class="filter-buttons">
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<ion-button
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
size="small"
(click)="toggleComunitaFilter()"
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
class="filter-chip"
style="margin: 0;">
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
</ion-button>
<div
*ngIf="comunitaService.comunitaCode()"
(click)="editComunitaCode($event)"
style="position: absolute; top: -6px; right: -6px; z-index: 99999; background: var(--ion-color-secondary); color: white; border-radius: 50%; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; border: 1.5px solid #1a1a1a; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"
class="floating-edit-badge">
<ion-icon name="create-outline" style="font-size: 0.85rem; pointer-events: none;"></ion-icon>
</div>
</div>
<ion-button
*ngIf="settingsService.showEditor()"
[fill]="showOnlyMine() ? 'solid' : 'outline'"
@@ -183,10 +204,12 @@
(cdkDropListDropped)="drop($event)"
class="transparent-list">
<!-- Premium Suggested Mass Card (Collapsible) -->
<!-- Premium Suggested Mass Card (Collapsible with Swipe support) -->
<div class="suggeriti-header-card glass ion-margin-bottom"
*ngIf="showSuggeriti() && cantiLettureService.data()"
(click)="isMassCardExpanded.set(!isMassCardExpanded())"
(touchstart)="onMassCardTouchStart($event)"
(touchend)="onMassCardTouchEnd($event)"
[class.expanded]="isMassCardExpanded()">
<div class="card-inner ion-padding">
<div class="card-header-row">
@@ -194,7 +217,17 @@
<span class="card-label">SINTESI MESSA</span>
<span class="card-date-compact" *ngIf="!isMassCardExpanded()">{{ getSelectedMassFormattedDate() }}</span>
</div>
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon"></ion-icon>
<div class="card-header-right" style="display: flex; align-items: center; gap: 8px;">
<div class="mass-nav-buttons" style="display: flex; align-items: center; gap: 4px; z-index: 10;" (click)="$event.stopPropagation()">
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(-1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
<ion-icon slot="icon-only" name="chevron-back-outline" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
<ion-icon slot="icon-only" name="chevron-forward-outline" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
</div>
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon" style="margin-left: 4px;"></ion-icon>
</div>
</div>
<div class="expandable-content" [class.show]="isMassCardExpanded()">
@@ -234,7 +267,7 @@
<div class="top-row">
<ion-label class="info-section">
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary)">
{{ canto.titolo }}
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span>{{ canto.titolo }}
</h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
+1 -1
View File
@@ -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 {
+229 -9
View File
@@ -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<number>(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;
}
}
+10 -1
View File
@@ -4,10 +4,19 @@
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon>
</div>
<ion-button fill="clear" (click)="toggleChords()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
[color]="showChords() ? 'secondary' : 'medium'"
style="font-size: 1.3rem;">
</ion-icon>
</ion-button>
</ion-buttons>
<ion-title class="outfit-font wrapped-title">
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
<span class="canto-number" *ngIf="canto()?.id_canti">{{ canto()?.id_canti }}</span>
<span class="canto-number" *ngIf="canto()?.id_canti" [style.color]="getCommunitySongNumber(canto()) !== null ? 'var(--ion-color-secondary)' : ''">
{{ getCommunitySongNumber(canto()) || canto()?.id_canti }}
</span>
<span class="title-text">{{ canto()?.titolo || 'Player' }}</span>
</div>
</ion-title>
+49
View File
@@ -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();
}
+3 -1
View File
@@ -25,7 +25,9 @@
</div>
<div class="song-info">
<span class="canto-number">{{ song.id_canti }}</span>
<span class="song-title">{{ song.titolo }}</span>
<span class="song-title">
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
</span>
</div>
<div class="example-custom-placeholder" *cdkDragPlaceholder></div>
</div>
+9
View File
@@ -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;
}
}
+41 -28
View File
@@ -61,37 +61,31 @@
</ion-item>
</div>
<!-- Messa di Riferimento Selection -->
<!-- Comunità -->
<div class="settings-group glass ion-margin-bottom">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title">
Messa di Riferimento (Suggeriti)
</h2>
</div>
<div class="select-item-container ion-padding-horizontal ion-padding-bottom">
<div class="select-label-row">
<ion-icon name="calendar-outline" color="secondary" class="select-icon"></ion-icon>
<div class="select-text-group">
<h2 class="settings-item-title outfit-font">Seleziona Messa</h2>
<p class="settings-item-subtitle outfit-font">Scegli la messa per i canti consigliati</p>
</div>
</div>
<div class="select-box-wrapper">
<ion-select [value]="cantiLettureService.selectedMassDate()"
(ionChange)="onMassChange($event)"
interface="action-sheet"
class="custom-select-block"
placeholder="Seleziona una messa...">
<ion-select-option *ngFor="let mass of cantiLettureService.availableMasses()" [value]="mass.date">
{{ mass.formattedLabel }}
</ion-select-option>
</ion-select>
</div>
</div>
<ion-item class="transparent-item" lines="none">
<ion-icon name="people-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Comunità</h2>
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="settingsService.toggleComunitaEnabled()" color="secondary"></ion-toggle>
</ion-item>
</div>
<!-- Invio Dati Statistici -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none">
<ion-icon name="analytics-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Invio dati statistici</h2>
<p class="settings-item-subtitle">Invia statistiche di utilizzo e tonalità</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.invioDatiStatistici()" (ionChange)="settingsService.toggleInvioDatiStatistici()" color="secondary"></ion-toggle>
</ion-item>
</div>
<!-- Display Mode Section -->
<div class="settings-group glass ion-margin-bottom">
<div class="group-header ion-padding-start ion-padding-top">
@@ -117,6 +111,8 @@
</div>
<div class="settings-group glass ion-margin-top" *ngIf="settingsService.showEditor()">
<ion-item class="transparent-item" lines="none" (click)="myCantiService.sendAllMyCanti()" detail="true" button *ngIf="myCantiService.myCanti().length > 0">
<ion-icon name="send-outline" slot="start" color="secondary"></ion-icon>
@@ -185,6 +181,23 @@
</div>
</div>
<div class="legend-item">
<div style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
<div class="legend-icon-wrapper">
<ion-icon name="text-outline" color="medium"></ion-icon>
</div>
<div class="legend-icon-wrapper">
<ion-icon name="musical-notes-outline" color="secondary"></ion-icon>
</div>
</div>
<div class="legend-text" style="padding-top: 6px;">
<h4 class="outfit-font">Testo / Accordi (icona in alto a destra)</h4>
<p class="outfit-font">
Alterna la visualizzazione tra solo testo e testo con accordi. L'icona con la <strong>T</strong> (grigia) indica la modalità testo; l'icona con le note musicali (arancione) indica la modalità accordi. Un tocco cambia modalità all'istante.
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
+9 -1
View File
@@ -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();
}
}
+5 -5
View File
@@ -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);
}
}
}
+194
View File
@@ -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<string>('');
public comunitaNome = signal<string>('');
public comunitaMail = signal<string>('');
public comunitaCantiIds = signal<(number | string)[]>([]);
public comunitaCantiInfo = signal<ParrocchiaCantiItem[]>([]);
public comunitaCantiSettings = signal<CantiSettingsItem[]>([]);
public isFilterActive = signal<boolean>(false);
public loading = signal<boolean>(false);
public loadingProgress = signal<number>(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<T>(url: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const req = new HttpRequest('GET', url, {
reportProgress: true,
responseType: 'json'
});
this.http.request<T>(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<boolean> {
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<GetAllAppTablesResponse>(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<StaticComunitaData>(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;
}
}
+78 -11
View File
@@ -11,7 +11,14 @@ export class SettingsService {
public fullscreenMode = signal<boolean>(false);
/** Browser Fullscreen state: true = fullscreen active */
public browserFullscreen = signal<boolean>(!!document.fullscreenElement);
public browserFullscreen = signal<boolean>(
!!(
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<boolean>(true);
@@ -22,6 +29,12 @@ export class SettingsService {
/** Schermo sempre acceso: true = attiva Screen Wake Lock */
public keepScreenOn = signal<boolean>(false);
/** Funzionalità Comunità: true = il chip comunità è visibile nella home */
public comunitaEnabled = signal<boolean>(true);
/** Invio dati statistici: true = invia pacchetto dati statistici */
public invioDatiStatistici = signal<boolean>(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());
}
}
+192
View File
@@ -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);
}
});
}
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.05.18.0125';
export const VERSION = '2026.05.18.1550';