Compare commits

...

10 Commits

64 changed files with 7803 additions and 1041 deletions
+6 -1
View File
@@ -1,4 +1,9 @@
FTP_PASSWORD=cantiDavid@72
FTP_THREADS=30
FTP_THREADS=100
BASE_HREF=/ionic/
CONTACT_EMAIL=info@canticristiani.it
API_AUTH_USER=canti
API_AUTH_PASS=antani2026
VPS_HOST=185.193.67.105
VPS_USER=root
VPS_PATH=/var/docker/canticristiani-pwa/html
+2 -2
View File
@@ -12,7 +12,7 @@ fi
FTP_HOST="ftp.canticristiani.it"
FTP_USER="canticristiani.it"
FTP_PASS="$FTP_PASSWORD"
SOURCE_URL="http://185.193.67.105:3000/cantiletture.json"
SOURCE_URL="https://api.canticristiani.it/cantiletture.json"
REMOTE_PATH="api/cantiletture.json"
if [ -z "$FTP_PASS" ]; then
@@ -23,7 +23,7 @@ fi
# --- Download del file ---
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
TEMP_FILE=$(mktemp)
if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"; then
echo "❌ Errore nel download dei dati."
rm "$TEMP_FILE"
exit 1
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
echo "Creazione del backup del repository Git locale..."
zip -r git_backup.zip . -x "node_modules/*" ".angular/*" "www/*" "platforms/*" "plugins/*" ".ionic/*" "dist/*" ".nx/*" "*/.DS_Store" "git_backup.zip" "backup.sh"
echo "Backup completato! Creato il file git_backup.zip."
+5
View File
@@ -0,0 +1,5 @@
{
"id_comunita": "123456",
"nome_comunita": "Comunità Test 123456",
"canti": [378, 379, 380]
}
+27
View File
@@ -17,6 +17,28 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
echo "export const VERSION = '$VERSION';" > src/app/version.ts
echo "🏷️ Versione aggiornata a: $VERSION"
# --- Caricamento variabili d'ambiente ---
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
# --- Configurazione Email e API Parametriche ---
node -e "
const fs = require('fs');
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
const apiUser = process.env.API_AUTH_USER || 'canti';
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
if (fs.existsSync(file)) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
fs.writeFileSync(file, content, 'utf8');
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
}
});
"
# 2. Build dell'applicazione
echo "📦 Compilazione in corso (Production Build)..."
@@ -37,6 +59,11 @@ fi
echo "✅ Build completata con successo in: $DIST_PATH"
# --- Genera version.json per il polling PWA ---
BUILD_TIMESTAMP=$(date +%s)000
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
# 2.5 In locale usiamo la root
echo "📁 Build pronta nella root..."
# Nessuna sottocartella ionic necessaria in locale
+43 -68
View File
@@ -8,35 +8,62 @@ else
exit 1
fi
# --- Configurazione Parallelismo ---
THREADS=${1:-${FTP_THREADS:-30}}
# --- Configurazione Target e Parallelismo ---
TARGET="tophost"
THREADS=""
# --- Configurazione FTP per ROOT www.canticristiani.it ---
if [ "$1" = "contabo" ] || [ "$1" = "tophost" ]; then
TARGET="$1"
THREADS="$2"
else
# Se il primo argomento è un numero, indica il numero di thread per tophost
if [[ "$1" =~ ^[0-9]+$ ]]; then
THREADS="$1"
fi
fi
THREADS=${THREADS:-${FTP_THREADS:-100}}
export FTP_THREADS=$THREADS
echo "🎯 Target di deploy: $TARGET"
# --- Validazione configurazione basata sul target ---
if [ "$TARGET" = "tophost" ]; then
FTP_HOST="ftp.canticristiani.it"
FTP_USER="canticristiani.it"
FTP_PASS="$FTP_PASSWORD"
REMOTE_DIR="" # Carica nella root della cartella FTP
REMOTE_DIR=""
if [ -z "$FTP_PASS" ]; then
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
exit 1
fi
else
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
exit 1
fi
fi
# --- Aggiornamento Versione ---
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "export const VERSION = '$VERSION';" > src/app/version.ts
echo "🏷️ Versione aggiornata a: $VERSION"
# --- Configurazione Email Parametrica ---
# --- Configurazione Email e API Parametriche ---
node -e "
const fs = require('fs');
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
const apiUser = process.env.API_AUTH_USER || 'canti';
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
if (fs.existsSync(file)) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
fs.writeFileSync(file, content, 'utf8');
console.log(\`📧 Aggiornata email di contatto in \${file} a: \${envEmail}\`);
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
}
});
"
@@ -51,68 +78,16 @@ if [ ! -d "www" ]; then
exit 1
fi
# --- Upload via FTP ---
echo "🚀 2/2 Caricamento parallelo ($THREADS connessioni) su $FTP_HOST nella ROOT..."
cd www
# --- Genera version.json per il polling PWA ---
BUILD_TIMESTAMP=$(date +%s)000
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
TOTAL_FILES=$(find . -type f | wc -l | xargs)
PROGRESS_LOG=$(mktemp)
ERROR_LOG=$(mktemp)
show_progress() {
local current=0
while [ "$current" -lt "$TOTAL_FILES" ]; do
current=$(wc -l < "$PROGRESS_LOG" | xargs)
local percent=$((current * 100 / TOTAL_FILES))
local bar_size=20
local num_hash=$((percent * bar_size / 100))
local bar=$(printf "%${num_hash}s" | tr ' ' '#' 2>/dev/null)
local spaces=$(printf "%$((bar_size - num_hash))s" | tr ' ' '-')
printf "\r[%-20s] %d%% (%d/%d) caricati..." "$bar$spaces" "$percent" "$current" "$TOTAL_FILES"
sleep 0.2
done
}
show_progress &
BAR_PID=$!
# Lancio dei job paralleli
find . -type f | while read -r file; do
REMOTE_FILE_PATH=${file#./}
# Eseguiamo curl e segniamo SEMPRE il progresso (anche se fallisce)
(
# Usiamo --retry per gestire errori temporanei di connessione
if ! curl -s --retry 3 --retry-delay 1 --connect-timeout 10 -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$file" "ftp://$FTP_HOST/$REMOTE_FILE_PATH"; then
echo "$REMOTE_FILE_PATH" >> "$ERROR_LOG"
fi
echo 1 >> "$PROGRESS_LOG"
) &
# Gestione THREADS
while [ $(jobs -r | wc -l) -ge "$THREADS" ]; do
sleep 0.05
done
done
# Attendi fine caricamenti
wait
# Stop barra
sleep 0.5
kill $BAR_PID 2>/dev/null
echo ""
# Controllo errori
ERRORS=$(wc -l < "$ERROR_LOG" | xargs)
if [ "$ERRORS" -gt 0 ]; then
echo "⚠️ Deploy completato con $ERRORS errori."
echo "I seguenti file non sono stati caricati:"
cat "$ERROR_LOG"
# --- Upload ---
if [ "$TARGET" = "tophost" ]; then
echo "🚀 Upload via FTP su Tophost in corso..."
python3 scratch/deploy_ftp.py
else
echo "✅ Deploy completato con successo nella ROOT senza errori!"
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
rsync -avz --delete www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
fi
# Cleanup
rm "$PROGRESS_LOG" "$ERROR_LOG"
printf "L'app è disponibile su https://www.canticristiani.it/\n"
BIN
View File
Binary file not shown.
+84
View File
@@ -0,0 +1,84 @@
# Specifiche Tecniche: Integrazione Servizio `/miei` con PWA Canti
Questo documento descrive le specifiche dell'endpoint `/miei` implementato sul server Node.js e fornisce le linee guida per implementare la sincronizzazione e l'invio delle playlist locali e dei canti personalizzati ("canti miei") dalla PWA.
---
## 🌐 Dettagli dell'Endpoint `/miei`
- **Metodo HTTP**: `POST`
- **URL di Sviluppo**: `http://localhost:3000/miei` (o l'IP/porta del tuo server in produzione)
- **Content-Type**: `application/json`
- **Limite Dimensione Body**: `10MB` (per supportare liste e testi completi di canti)
### 🔑 Identificazione Utente (`uid`)
L'endpoint richiede l'identificativo unico dell'utente (`uid`). Può essere trasmesso in tre modi (in ordine di priorità):
1. **Header HTTP**: `x-user-uid` (scelta consigliata) o `uid`
2. **Query Parameter**: `?uid=<user_uid>`
3. **Wrapper nel Body**: Se il corpo è un oggetto del tipo `{ "uid": "...", "canti": [...] }`
> [!WARNING]
> L'UID dell'utente deve essere una stringa valida e sicura. Sono accettati solo caratteri alfanumerici, trattini e trattini bassi (`^[a-zA-Z0-9_\-]+$`). Qualsiasi tentativo di path traversal (es. contenente `.` o `/`) restituirà un errore `400 Bad Request`.
---
## 📦 Formato dei Dati Richiesto (Payload)
L'endpoint si aspetta di ricevere un **array JSON** contenente la lista dei canti dell'utente (formato analogo a `canti.json`).
### Esempio di Payload (Array di Canti)
```json
[
{
"id_canti": 9001,
"titolo": "Mio Canto Personalizzato 1",
"momenti": ["Ingresso"],
"periodi": ["Lode"],
"testo": "Testo del mio canto personalizzato...\nRit: Alleluia!"
},
{
"id_canti": 9002,
"titolo": "Mio Canto Personalizzato 2",
"momenti": ["Comunione"],
"periodi": [],
"testo": "Testo del secondo canto..."
}
]
```
---
## 🔄 Comportamento del Server
1. **Ricezione e Validazione**:
- Estrae l'UID ed il payload dei canti.
- Valida l'UID (sicurezza path traversal) e verifica che il payload sia un array JSON. In caso contrario, risponde con `400 Bad Request`.
2. **Logica di Backup**:
- Se nella cartella `data/` del server esiste già un file associato all'utente (`data/<uid>.json`), ne crea automaticamente una copia di backup rinominandola o copiandola in `data/<uid>.bak.json`.
3. **Salvataggio**:
- Scrive il nuovo JSON in `data/<uid>.json`.
- Risponde con `200 OK` e un JSON di conferma:
```json
{
"success": true,
"message": "File salvato con successo.",
"uid": "utente_test_123",
"backupCreated": true
}
```
---
## 🛠️ Requisiti per l'Implementazione nella PWA (Client Locale Mac M1)
L'istanza di Antigravity che lavora sul codice della PWA locale dovrà implementare una funzione di sincronizzazione (es. `syncLocalDataToServer`) che esegua i seguenti passi:
1. **Recupero dei dati locali**:
- Estrarre le playlist locali e i canti personalizzati creati dall'utente (solitamente salvati in `localStorage`, `IndexedDB`, o altro database locale).
2. **Normalizzazione e Formattazione**:
- Formattare e unire queste informazioni in un unico array JSON strutturato con i campi chiave per ciascun canto (`id_canti`, `titolo`, `momenti`, `periodi`, `testo`).
3. **Invio al Server**:
- Effettuare una chiamata `fetch` (POST) verso l'endpoint `/miei`.
- Passare l'UID dell'utente autenticato (es. da Firebase Auth o altro sistema di sessione) tramite l'header `x-user-uid`.
4. **Gestione del Feedback**:
- Mostrare all'utente una notifica di successo o gestire eventuali errori di rete o validazione.
+54
View File
@@ -0,0 +1,54 @@
# Piano di Implementazione: Importazione Playlist e Canti della Comunità
Questo piano descrive i dettagli tecnici per l'aggiunta di una funzione che consente di importare localmente tutte le playlist e i canti personalizzati di una comunità attiva.
## User Review Required
> [!IMPORTANT]
> L'importazione comporterà il salvataggio dei canti personalizzati della comunità direttamente nei "Miei Canti" dell'utente (evitando duplicati) e la creazione di playlist locali. Le playlist locali create includeranno:
> 1. Una playlist principale con il nome della comunità stessa contenente tutti i canti della comunità con le rispettive tonalità, velocità di scorrimento e testi personalizzati/aggiuntivi.
> 2. Tutte le singole scalette/playlist della comunità importate come playlist locali indipendenti e modificabili.
## Proposed Changes
### Servizi e Logica di Business
#### [MODIFY] [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
Aggiungeremo un metodo helper o esporremo le funzioni di salvataggio di massa per le playlist importate, garantendo che le impostazioni dei brani (`songSettings` contenenti tonalità e velocità di scorrimento) siano preservate correttamente.
### Pagina Impostazioni (Settings Page)
#### [MODIFY] [settings.page.html](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.html)
Nel gruppo "Comunità", quando una comunità è attiva, aggiungeremo un nuovo pulsante sotto i dettagli della comunità:
- **Pulsante**: "Importa Playlist e Canti" con icona `cloud-download-outline`.
#### [MODIFY] [settings.page.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.ts)
Implementeremo il metodo `importComunitaPlaylists()` che svolgerà i seguenti passaggi:
1. **Verifica dello Stato**: Si assicura che una comunità sia attiva e che i dati siano caricati.
2. **Importazione dei Canti Personalizzati**:
- Scorrerà i canti di `comunitaService.comunitaCantiPersonali()`.
- Per ciascun canto, verificherà se è già presente nei "Miei Canti" (tramite `myCantiService.myCanti()`).
- Se assente, lo aggiungerà con prefisso ID `my_` ed effettuerà il salvataggio in locale su `MyCantiService`.
3. **Creazione della Playlist della Comunità**:
- Creerà una playlist avente come nome il nome della comunità stessa.
- Popolerà i canti associando sia i canti standard sia i canti personali importati.
- Assocerà le impostazioni di tonalità e velocità di scorrimento da `comunitaCantiSettings()`. Per la velocità, si utilizzerà la formula di conversione standard (es. `Math.round(speed / 100)`).
4. **Importazione delle Scalette (Playlist) Aggiuntive**:
- Per ciascuna scaletta in `comunitaService.comunitaScalette()`, creerà una playlist locale corrispondente.
- Per ciascun canto della scaletta, cercherà se esiste un'impostazione in `comunitaCantiSettings()` per impostare tonalità e velocità specifiche all'interno della playlist.
5. **Salvataggio e Feedback**:
- Salverà tutte le nuove playlist tramite `playlistService.savePlaylist()`.
- Mostrerà un alert di conferma iniziale e un toast di successo finale.
## Verification Plan
### Manual Verification
- Attivare una comunità di prova (es. con codice noto o inserendone uno valido).
- Andare nelle impostazioni e cliccare sul nuovo pulsante "Importa Playlist e Canti".
- Verificare la comparsa dell'alert di conferma.
- Confermare l'importazione.
- Verificare tramite Toast e navigando nella home che:
- Sia presente una playlist locale con il nome della comunità contenente tutti i canti ordinati e configurati.
- Siano presenti le playlist secondarie della comunità convertite in playlist locali.
- Le tonalità modificate e le velocità di scorrimento siano state importate correttamente per ciascun canto all'interno delle playlist.
- I canti personali/aggiuntivi siano stati importati nella sezione "Miei Canti" e siano visualizzabili.
+159
View File
@@ -0,0 +1,159 @@
# Piano delle Azioni per la Generazione di una Batteria di Test (`.spec.ts`)
Questo documento descrive in dettaglio la strategia, l'analisi dello stato attuale e l'elenco delle azioni sequenziali necessarie per implementare una suite completa di test unitari e di integrazione per tutte le classi (servizi, pagine e componenti) dell'applicazione Angular/Ionic.
---
## 📊 Analisi dello Stato Attuale dei Test
L'applicazione utilizza **Angular v20**, **Ionic v8**, e il framework di test standard **Jasmine** abbinato al test runner **Karma**.
Un'analisi del codice ha rilevato la seguente situazione per ciascun componente chiave:
### 1. Servizi (`src/app/services`)
I servizi gestiscono la logica di business fondamentale (audio, sincronizzazione, storage, stato).
* ❌ `audio-engine.service.ts` — **Nessun test**
* ❌ `canti-letture.service.ts` — **Nessun test**
* ❌ `canti.service.ts` — **Nessun test**
* ❌ `comunita.service.ts` — **Nessun test**
* ❌ `connectivity.service.ts` — **Nessun test**
* ❌ `lyrics-parser.service.ts` — **Nessun test**
* ❌ `media-session.service.ts` — **Nessun test**
* ⚠️ `my-canti.service.ts` — **Spec esistente ma corrotto/obsoleto** (`my-canti.spec.ts` fa riferimento a una classe `MyCanti` che non esiste, la classe reale è `MyCantiService`).
* ❌ `playlist.service.ts` — **Nessun test**
* ❌ `settings.service.ts` — **Nessun test**
* ❌ `stats.service.ts` — **Nessun test**
* ❌ `theme.service.ts` — **Nessun test**
* ❌ `youtube-player.service.ts` — **Nessun test**
### 2. Pagine (`src/app/pages` & `src/app/home`)
Le pagine gestiscono la visualizzazione e l'interazione con l'utente.
* ⚠️ `home.page.ts` — Spec esistente (`home.page.spec.ts`) ma limitato al boilerplate base (verifica solo la creazione).
* ⚠️ `display.page.ts` — Spec esistente (`display.page.spec.ts`) solo boilerplate base.
* ⚠️ `player.page.ts` — Spec esistente (`player.page.spec.ts`) solo boilerplate base.
*`playlist.page.ts`**Nessun test**
* ⚠️ `propose-canto.page.ts` — Spec esistente (`propose-canto.page.spec.ts`) solo boilerplate base.
*`settings.page.ts`**Nessun test**
### 3. Componenti (`src/app/components`)
*`qr-scanner/qr-scanner.component.ts`**Nessun test**
---
## 🛠️ Strategia di Mocking e Test in Angular/Ionic
Per testare efficacemente queste classi in isolamento, definiremo una strategia di mocking standard:
1. **Storage di Ionic (`@ionic/storage-angular`)**:
Molti servizi (`MyCantiService`, `SettingsService`, `ComunitaService`) dipendono dallo Storage. Creeremo un mock per simulare i metodi `get`, `set` e `remove` usando una mappa in memoria.
2. **Controller di Ionic (`ToastController`, `ModalController`, `AlertController`)**:
Utilizzeremo `jasmine.createSpyObj` per simulare la creazione e la presentazione dei componenti UI di Ionic senza istanziarli realmente nel DOM durante i test dei servizi.
3. **Ambiente HTML5 (Audio, Window)**:
Servizi come `AudioEngineService` e `YoutubePlayerService` interagiscono con le API audio del browser e l'oggetto `window`. Useremo dei mock o spy su `Audio`, `window.location`, ecc.
---
## 🚀 Elenco Dettagliato delle Azioni da Compiere
Le azioni sono suddivise in 6 fasi logiche per garantire un approccio sistematico e sicuro.
### Fase 1: Verifica dell'Ambiente e Correzione dei Test Esistenti
Prima di scrivere nuovi test, dobbiamo assicurarci che l'infrastruttura di test esistente sia stabile e funzionante.
- [ ] **Azione 1.1: Esecuzione iniziale dei test**
* Eseguire `npm run test` (o `ng test --watch=false`) per verificare se la suite iniziale compila ed è verde.
- [ ] **Azione 1.2: Correzione di `my-canti.spec.ts`**
* Rinominare il file in `my-canti.service.spec.ts` per uniformità.
* Correggere gli import: importare `MyCantiService` anziché `MyCanti`.
* Configurare il `TestBed` fornendo il mock per `Storage`, `CantiService` e `ToastController`.
* Verificare che il test passi con successo.
- [ ] **Azione 1.3: Aggiornamento dei boilerplate delle pagine esistenti**
* Risolvere eventuali errori nei test pregenerati (`home.page.spec.ts`, `display.page.spec.ts`, `player.page.spec.ts`, `propose-canto.page.spec.ts`) fornendo i moduli minimi (`IonicModule`, `RouterTestingModule`) e i mock dei servizi iniettati nei loro costruttori.
---
### Fase 2: Implementazione dei Test sui Servizi (Core Logico)
I servizi sono la priorità in quanto non hanno dipendenze grafiche complesse e contengono la logica di business principale.
- [ ] **Azione 2.1: Test per `connectivity.service.ts` e `theme.service.ts`**
* *Perché:* Sono i più semplici e privi di dipendenze esterne pesanti.
* *Test:* Cambiamenti di stato della rete online/offline, applicazione corretta delle classi CSS per i temi (scuro/chiaro).
- [ ] **Azione 2.2: Test per `lyrics-parser.service.ts`**
* *Casi di test:* Parsing corretto dei testi dei canti con accordi tra parentesi (es. `[Do]`), gestione delle righe vuote, estrazione del testo pulito, formattazione.
- [ ] **Azione 2.3: Test per `settings.service.ts`**
* *Casi di test:* Caricamento delle impostazioni di default da storage, salvataggio di nuove impostazioni, gestione del cambio di dimensione del testo, export/import delle impostazioni.
- [ ] **Azione 2.4: Test per `stats.service.ts`**
* *Casi di test:* Incremento delle statistiche di visualizzazione di un canto, persistenza su storage, generazione dei report dei canti più cantati.
- [ ] **Azione 2.5: Test per `canti.service.ts` e `canti-letture.service.ts`**
* *Casi di test:* Lettura del database dei canti, filtri di ricerca per titolo/testo/momento liturgico, associazione tra letture del giorno e canti consigliati (sulla base delle strategie liturgiche descritte in `liturgia_strategy.md`).
- [ ] **Azione 2.6: Test per `comunita.service.ts`**
* *Casi di test:* Gestione del codice comunità, sincronizzazione dei canti della comunità tramite chiamate HTTP (mocking di `HttpClient`), salvataggio in storage locale.
- [ ] **Azione 2.7: Test per `playlist.service.ts`**
* *Casi di test:* Creazione, modifica e cancellazione di playlist, riordino dei canti all'interno di una playlist, salvataggio e ripristino da storage.
- [ ] **Azione 2.8: Test per `media-session.service.ts`**
* *Casi di test:* Integrazione con l'API `navigator.mediaSession` del browser, aggiornamento dei metadati audio (titolo, autore, copertina), gestione degli eventi di riproduzione/pausa del sistema operativo.
- [ ] **Azione 2.9: Test per `audio-engine.service.ts` e `youtube-player.service.ts`**
* *Casi di test:* Avvio, pausa, stop e seek delle tracce audio locali e dei video YouTube, gestione degli eventi di fine riproduzione, aggiornamento dello stato tramite Signals/RxJS.
---
### Fase 3: Implementazione dei Test sui Componenti Condivisi
- [ ] **Azione 3.1: Test per `qr-scanner.component.ts`**
* *Casi di test:* Inizializzazione della fotocamera, gestione dei permessi negati, decodifica del codice QR (simulando l'evento del decoder), emissione del codice scansionato tramite `@Output()`, interruzione dello streaming video alla distruzione del componente.
---
### Fase 4: Implementazione e Arricchimento dei Test sulle Pagine (UI & Controller)
Qui testeremo l'integrazione tra i servizi (mockati) e l'interfaccia utente di Ionic.
- [ ] **Azione 4.1: Test approfonditi per `home.page.ts`**
* *Casi di test:* Visualizzazione dell'elenco dei canti, funzionamento della barra di ricerca (filtro istantaneo), apertura dei dettagli del canto, gestione della navigazione verso le altre pagine.
- [ ] **Azione 4.2: Test approfonditi per `player.page.ts`**
* *Casi di test:* Interazione con i pulsanti di play/pause, scorrimento del testo a tempo, trasposizione degli accordi (es. +1 semitono, -1 semitono) con aggiornamento dinamico del testo a schermo.
- [ ] **Azione 4.3: Test approfonditi per `display.page.ts`**
* *Casi di test:* Rendering corretto del testo del canto, applicazione del tema visivo e della dimensione del font dalle impostazioni, gestione del blocco dello spegnimento dello schermo (se implementato).
- [ ] **Azione 4.4: Creazione e test per `playlist.page.ts`**
* *Casi di test:* Visualizzazione dell'elenco delle playlist dell'utente, creazione di una nuova playlist tramite popup, aggiunta di canti, eliminazione di canti con swipe.
- [ ] **Azione 4.5: Test approfonditi per `propose-canto.page.ts`**
* *Casi di test:* Validazione del form di proposta (titolo obbligatorio, testo minimo), generazione della mailto URL corretta con il body in formato JSON, comportamento del pulsante di invio.
- [ ] **Azione 4.6: Creazione e test per `settings.page.ts`**
* *Casi di test:* Toggle del tema scuro (con verifica dell'applicazione al DOM), selezione della dimensione del font, reset dei dati locali con richiesta di conferma tramite `AlertController`.
---
### Fase 5: Raccolta delle Metriche di Coverage e Rifinitura
- [ ] **Azione 5.1: Configurazione del report di coverage**
* Assicurarsi che `karma.conf.js` sia configurato per esportare i dati in formato `lcov` o `html` (tramite `karma-coverage`).
- [ ] **Azione 5.2: Generazione del report**
* Eseguire `ng test --code-coverage --watch=false`.
* Esaminare la cartella `coverage/` generata per individuare eventuali rami logici (branches) o righe non coperte nei servizi core.
- [ ] **Azione 5.3: Incremento mirato del coverage**
* Aggiungere test specifici per coprire i casi limite (*edge cases*) e la gestione degli errori (es. fallimento delle chiamate HTTP, storage corrotto).
---
### Fase 6: Automazione in CI/CD (Opzionale ma Consigliato)
- [ ] **Azione 6.1: Configurazione test headless**
* Configurare Karma per eseguire i test in modalità headless usando `ChromeHeadless` su sistemi di Continuous Integration (es. GitHub Actions).
* Aggiungere uno script npm `test:ci`: `"test:ci": "ng test --watch=false --browsers=ChromeHeadless"`.
---
## 📈 Tabella di Marcia Consigliata
```mermaid
gantt
title Roadmap per la Copertura dei Test
dateFormat YYYY-MM-DD
section Fase 1
Verifica & Fix Esistenti :active, 2026-05-21, 2d
section Fase 2
Test Servizi Core : 2026-05-23, 5d
section Fase 3 & 4
Test Componenti & Pagine : 2026-05-28, 5d
section Fase 5 & 6
Coverage & CI/CD : 2026-06-02, 2d
```
> [!TIP]
> **Consiglio per l'efficienza**: Si raccomanda di iniziare ad implementare i test partendo dai servizi a più basso livello (come `ConnectivityService`, `ThemeService`, `LyricsParserService`) perché sono privi di dipendenze e consentono di stabilire rapidamente dei pattern di test solidi prima di affrontare servizi più complessi o interfacce grafiche.
+9
View File
@@ -8,3 +8,12 @@
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
RewriteRule ^ index.html [L]
</IfModule>
<IfModule mod_headers.c>
# Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker
<FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest|version\.json">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires 0
</FilesMatch>
</IfModule>
+6
View File
@@ -6,6 +6,12 @@
"start_url": "/",
"theme_color": "#3880ff",
"background_color": "#ffffff",
"protocol_handlers": [
{
"protocol": "web+canti",
"url": "/?url=%s"
}
],
"icons": [
{
"src": "assets/icons/icon-72x72.png",
+13
View File
@@ -0,0 +1,13 @@
import urllib.request
import json
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
print("Fetching...")
with urllib.request.urlopen(req) as res:
data = json.loads(res.read().decode("utf-8"))
settings = data.get("canti_settings", {}).get("data", [])
print(f"Total settings: {len(settings)}")
for s in settings[:10]:
print(s)
+154
View File
@@ -0,0 +1,154 @@
import os
import sys
import threading
from ftplib import FTP, error_perm
from concurrent.futures import ThreadPoolExecutor, as_completed
thread_local = threading.local()
def load_env():
env = {}
if os.path.exists('.env'):
with open('.env', 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '=' in line:
key, val = line.split('=', 1)
env[key.strip()] = val.strip()
return env
def get_ftp_connection(ftp_host, ftp_user, ftp_pass):
if not hasattr(thread_local, "ftp") or thread_local.ftp is None:
ftp = FTP(ftp_host, timeout=30)
ftp.login(ftp_user, ftp_pass)
ftp.passive = True
thread_local.ftp = ftp
return thread_local.ftp
def close_thread_connection():
if hasattr(thread_local, "ftp") and thread_local.ftp is not None:
try:
thread_local.ftp.quit()
except:
try:
thread_local.ftp.close()
except:
pass
thread_local.ftp = None
def ensure_remote_dir(ftp, path):
parts = path.replace('\\', '/').strip('/').split('/')
current = ""
for part in parts:
if not part:
continue
current += "/" + part
try:
ftp.mkd(current)
print(f"\nCreated remote dir: {current}")
except error_perm:
# Directory already exists or permission error (which is normal if it exists)
pass
def upload_file_task(local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock):
ftp_path = rel_path.replace('\\', '/')
remote_dir = os.path.dirname(ftp_path)
# Ensure remote directory exists
if remote_dir:
with created_dirs_lock:
if remote_dir not in created_dirs:
try:
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
ensure_remote_dir(ftp, remote_dir)
created_dirs.add(remote_dir)
except Exception as e:
thread_local.ftp = None
try:
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
ensure_remote_dir(ftp, remote_dir)
created_dirs.add(remote_dir)
except Exception as err2:
return False, ftp_path, f"Failed directory creation: {err2}"
# Upload the file
for attempt in range(3):
try:
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
with open(local_path, 'rb') as f:
ftp.storbinary(f"STOR {ftp_path}", f)
return True, ftp_path, None
except Exception as e:
thread_local.ftp = None # Force reconnect on next attempt
if attempt == 2:
return False, ftp_path, str(e)
return False, ftp_path, "Unknown error"
def main():
env = load_env()
ftp_host = "ftp.canticristiani.it"
ftp_user = "canticristiani.it"
ftp_pass = env.get("FTP_PASSWORD")
if not ftp_pass:
print("❌ Error: FTP_PASSWORD not found in .env")
sys.exit(1)
local_dir = "www"
if not os.path.isdir(local_dir):
print(f"❌ Error: Local dir '{local_dir}' not found.")
sys.exit(1)
files_to_upload = []
for root, dirs, files in os.walk(local_dir):
for file in files:
local_path = os.path.join(root, file)
rel_path = os.path.relpath(local_path, local_dir)
files_to_upload.append((local_path, rel_path))
total = len(files_to_upload)
uploaded = 0
errors = []
created_dirs = set()
created_dirs_lock = threading.Lock()
progress_lock = threading.Lock()
# Thread limit: default to 100 as requested
num_threads = int(os.environ.get("FTP_THREADS", env.get("FTP_THREADS", "100")))
print(f"🚀 Starting parallel upload of {total} files using {num_threads} threads...")
def run_task(item):
nonlocal uploaded
local_path, rel_path = item
success, ftp_path, err_msg = upload_file_task(
local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock
)
with progress_lock:
uploaded += 1
print(f"\rUploading [{uploaded}/{total}] {ftp_path}...", end="", flush=True)
if not success:
errors.append((ftp_path, err_msg))
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(run_task, item) for item in files_to_upload]
for future in as_completed(futures):
pass
# Cleanup connections
with ThreadPoolExecutor(max_workers=num_threads) as cleanup_executor:
cleanups = [cleanup_executor.submit(close_thread_connection) for _ in range(num_threads)]
for future in as_completed(cleanups):
pass
if errors:
print(f"\n⚠️ Deploy completed with {len(errors)} errors:")
for path, err in errors:
print(f" {path}: {err}")
sys.exit(1)
else:
print("\n\n✅ Deploy completed successfully with 100% files uploaded without errors!")
if __name__ == '__main__':
main()
+34
View File
@@ -0,0 +1,34 @@
import urllib.request
import json
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
print("Fetching...")
with urllib.request.urlopen(req) as res:
data = json.loads(res.read().decode("utf-8"))
canti_pers = data.get("canti_personali", {}).get("data", [])
states = {}
for cp in canti_pers:
state = cp.get("stato")
states[state] = states.get(state, 0) + 1
print("States found in canti_personali:")
for state, count in states.items():
print(f"- State {state}: {count} songs")
# Let's also check if there are any duplicate IDs between canti and canti_personali
canti_ids = {c["id_canti"] for c in data.get("canti", {}).get("data", [])}
canti_pers_ids = {cp["id_canti"] for cp in canti_pers}
intersection = canti_ids.intersection(canti_pers_ids)
print(f"Number of duplicate song IDs between global catalog and canti_personali: {len(intersection)}")
if len(intersection) > 0:
print("First 5 duplicate IDs:")
print(list(intersection)[:5])
# Find one example of duplicate
dup_id = list(intersection)[0]
global_dup = next(c for c in data["canti"]["data"] if c["id_canti"] == dup_id)
pers_dup = next(cp for cp in canti_pers if cp["id_canti"] == dup_id)
print(f"\nExample Duplicate (ID {dup_id}):")
print(f"Global: Title='{global_dup.get('titolo')}', Stato={global_dup.get('stato')}")
print(f"Personal: Title='{pers_dup.get('titolo')}', Stato={pers_dup.get('stato')}")
+11
View File
@@ -0,0 +1,11 @@
import urllib.request
url = "https://app.canticristiani.it/js/db.js"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req) as res:
content = res.read().decode("utf-8")
lines = content.split("\n")
print("================== js/db.js lines 1260 to 1310 ==================")
for i in range(1259, min(1310, len(lines))):
print(f"{i+1}: {lines[i]}")
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<style>
.chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
margin-right: 0.2em;
border: 1px solid blue;
}
.chord {
font-size: 0.75em;
font-weight: 700;
color: red;
height: 1.2em;
margin-bottom: -0.2em;
border: 1px solid red;
}
.seg-text {
white-space: pre;
border: 1px solid green;
}
</style>
</head>
<body>
<div>
ti amerò come
<span class="chord-segment"><span class="chord">LA</span><span class="seg-text"> </span></span>
<span class="chord-segment"><span class="chord">MI</span><span class="seg-text"></span></span>
</div>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
const textWords = [
{ text: 'come', bbox: { x0: 40, x1: 60 } },
{ text: 'sei', bbox: { x0: 70, x1: 90 } }
];
const chordWords = [
{ text: 'la', bbox: { x0: 65, x1: 75 } },
{ text: 'mi', bbox: { x0: 95, x1: 105 } }
];
function mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
let result = '';
const chordAssignments = new Map<any, any[]>();
chordWords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let closestWord: any = null;
let minDistance = Infinity;
textWords.forEach(textWord => {
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
const dist = Math.abs(chordX - wordXCenter);
if (dist < minDistance) {
minDistance = dist;
closestWord = textWord;
}
});
if (closestWord) {
if (!chordAssignments.has(closestWord)) {
chordAssignments.set(closestWord, []);
}
chordAssignments.get(closestWord)!.push(chord);
}
});
textWords.forEach((textWord, index) => {
const assignedChords = chordAssignments.get(textWord) || [];
// Split chords into before and after the word
const chordsBefore = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 <= textWord.bbox.x1);
const chordsAfter = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 > textWord.bbox.x1);
chordsBefore.sort((a, b) => a.bbox.x0 - b.bbox.x0);
chordsAfter.sort((a, b) => a.bbox.x0 - b.bbox.x0);
chordsBefore.forEach(chord => {
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
result += `[${cleanChord}]`;
});
result += textWord.text;
chordsAfter.forEach(chord => {
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
result += `[${cleanChord}]`;
});
if (index < textWords.length - 1) {
result += ' ';
}
});
return result;
}
console.log(mergeChordsAndLyrics(chordWords, textWords));
+184
View File
@@ -0,0 +1,184 @@
# Strategia SEO e Indicizzazione per la PWA dei Canti
Questo documento definisce la strategia tecnica per consentire l'indicizzazione dei testi dei canti da parte dei motori di ricerca (in particolare Googlebot) e la corretta generazione delle anteprime (social cards) su piattaforme di messaggistica e social media (WhatsApp, Telegram, Facebook, ecc.), mantenendo intatta l'architettura PWA (Progressive Web App) offline-ready.
---
## 1. Il Contesto Tecnologico
L'applicazione è sviluppata con:
- **Angular 20** (Framework Core)
- **Ionic 8** (UI & Routing integration)
- **Angular Service Worker (`@angular/service-worker`)** per le funzionalità offline della PWA.
---
## 2. La Strategia Selezionata: Prerendering / Static Site Generation (SSG)
Dato che i testi dei canti sono **dati statici** (non cambiano in base all'utente connesso e variano molto raramente), la soluzione ottimale è la **Static Site Generation (SSG)**, nota anche come **Prerendering**.
### Come Funziona la Sinergia SSG + PWA
1. **Fase di Build:** Durante la compilazione dell'app (`ng build`), Angular genera un file `index.html` statico e pre-renderizzato per ogni singolo canto (es. `/canti/tu-sei-sorgente/index.html`).
2. **Scansione dello Spider (SEO):** Quando Googlebot o i crawler dei social richiedono l'URL di un canto, il server o la CDN distribuiscono immediatamente il file HTML statico già popolato con il testo del canto e con i meta tag corretti.
3. **Idratazione e PWA (Client):** Quando un utente apre la pagina sul browser, Angular scarica i bundle JavaScript ed esegue l'**hydration** in background. L'applicazione "prende vita" come Single Page Application (SPA), attiva il Service Worker e abilita la navigazione offline e l'installabilità come PWA.
---
## 3. Fasi e Dettagli di Implementazione
### Fase 1: Struttura degli URL e Routing Semantico
Per facilitare la SEO, gli URL devono essere parlanti e privi di simboli di hash (`#`). Attualmente, l'applicazione utilizza già il routing basato su percorsi standard (PathLocationStrategy) in [app-routing.module.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/app-routing.module.ts).
È necessario definire una rotta dedicata per i singoli canti che accetti un parametro semantico (detto *slug* o *alias*), ad esempio:
```typescript
// src/app/app-routing.module.ts
const routes: Routes = [
// ... altre rotte
{
path: 'canti/:slug',
loadChildren: () => import('./pages/canto-detail/canto-detail.module').then(m => m.CantoDetailPageModule)
}
];
```
I collegamenti all'interno dell'applicazione per navigare verso i canti devono utilizzare il tag semantico `<a>` con la direttiva `routerLink`, per consentire a Googlebot di scoprire autonomamente tutte le pagine:
```html
<!-- EVITARE: pulsanti generici con eventi click gestiti in JS -->
<ion-item (click)="navigaAlCanto(canto.slug)">...</ion-item>
<!-- CONSIGLIATO: vero tag link HTML -->
<a [routerLink]="['/canti', canto.slug]" class="canto-link">
<ion-label>{{ canto.titolo }}</ion-label>
</a>
```
---
### Fase 2: Gestione dei Meta Tag Dinamici
Ogni canto deve avere un titolo e una descrizione univoci e ottimizzati per la SEO. In Angular si utilizzano i servizi `Title` e `Meta` di `@angular/platform-browser` per aggiornare i metadati all'inizializzazione del componente:
```typescript
// src/app/pages/canto-detail/canto-detail.page.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Title, Meta } from '@angular/platform-browser';
import { CantiService } from '../../services/canti.service';
@Component({
selector: 'app-canto-detail',
templateUrl: './canto-detail.page.html',
styleUrls: ['./canto-detail.page.scss'],
})
export class CantoDetailPage implements OnInit {
canto: any;
constructor(
private route: ActivatedRoute,
private cantiService: CantiService,
private titleService: Title,
private metaService: Meta
) {}
ngOnInit() {
const slug = this.route.snapshot.paramMap.get('slug');
if (slug) {
this.canto = this.cantiService.getCantoBySlug(slug);
this.updateSEOMetadata();
}
}
updateSEOMetadata() {
const titoloCompleto = `${this.canto.titolo} - Canti Cristiani`;
const descrizione = `Testo e accordi del canto "${this.canto.titolo}". ${this.canto.testo.substring(0, 150)}...`;
// Imposta il titolo della pagina
this.titleService.setTitle(titoloCompleto);
// Imposta i meta tag standard per la SEO
this.metaService.updateTag({ name: 'description', content: descrizione });
// Imposta i tag OpenGraph per la condivisione sui social (Facebook, WhatsApp, Telegram)
this.metaService.updateTag({ property: 'og:title', content: titoloCompleto });
this.metaService.updateTag({ property: 'og:description', content: descrizione });
this.metaService.updateTag({ property: 'og:type', content: 'article' });
this.metaService.updateTag({ property: 'og:url', content: `https://canti.cristiani.it/canti/${this.canto.slug}` });
// Se c'è un'immagine associata o una copertina di default
this.metaService.updateTag({ property: 'og:image', content: 'https://canti.cristiani.it/assets/og-cover.png' });
}
}
```
---
### Fase 3: Configurazione del Prerendering (SSG) in Angular 20
In Angular 20, l'abilitazione del server-side rendering e del prerendering statico durante la compilazione avviene aggiungendo il pacchetto SSR ufficiale:
```bash
ng add @angular/ssr
```
Questo comando configura automaticamente l'applicazione modificando `angular.json` e creando i file necessari per la compilazione lato server.
#### Configurazione delle rotte da pre-renderizzare
Poiché l'elenco dei canti è dinamico (es. risiede in file JSON o database), dobbiamo indicare ad Angular quali rotte generare staticamente durante il comando `ng build`.
Si crea un file di configurazione per definire le rotte o si utilizza uno script per estrarle dinamicamente:
1. **Creare un file delle rotte statiche** `prerender-routes.txt`:
```txt
/home
/settings
/canti/tu-sei-sorgente
/canti/re-dei-re
/canti/lodi-al-altissimo
```
2. **Automatizzare la generazione di questo file** inserendo uno script (es. `generate-routes.js`) da eseguire prima della build che legge l'elenco dei canti dal file JSON locale e scrive l'elenco dei percorsi in `prerender-routes.txt`.
3. **Configurare `angular.json`** per consumare questo file:
```json
"prerender": {
"discoverRoutes": false,
"routesFile": "prerender-routes.txt"
}
```
Al termine della build (`npm run build`), nella cartella di distribuzione (es. `dist/canticristiani/browser`) verranno generate cartelle fisiche con i file `index.html` pronti all'uso per ciascuna rotta definita.
---
### Fase 4: Sitemap.xml e Robots.txt
Per garantire che Googlebot trovi tempestivamente tutti i canti, è fondamentale generare un file `sitemap.xml` da posizionare nella radice del server web.
#### Esempio di `sitemap.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://canti.cristiani.it/home</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<!-- Generato dinamicamente per ogni canto -->
<url>
<loc>https://canti.cristiani.it/canti/tu-sei-sorgente</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
```
#### Esempio di `robots.txt`:
```txt
User-agent: *
Allow: /
Sitemap: https://canti.cristiani.it/sitemap.xml
```
---
## 4. Vantaggi e Risultati Attesi
- **Indicizzazione immediata:** Googlebot indicizzerà i testi dei canti all'istante, consentendo agli utenti di trovare la PWA cercando frammenti di testo o il titolo del canto direttamente su Google.
- **Anteprime nei Social Perfette:** La condivisione dei link sui canali di comunicazione mostrerà anteprime ricche (titolo corretto, frammento del testo del canto e logo dell'app).
- **Integrità PWA:** L'utente beneficerà di un caricamento iniziale ultra-veloce (grazie all'HTML pre-renderizzato) seguito dall'installazione offline e dall'esperienza fluida tipica dell'applicazione mobile.
+162
View File
@@ -1,4 +1,166 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
<div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div>
<!-- PWA Redirect Overlay -->
<div *ngIf="showRedirectOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<!-- Fase di redirect normale (in attesa di apertura) -->
<ng-container *ngIf="!redirectFailed()">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
</p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="openPwaManual()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
Apri Manualmente
</button>
<button (click)="stayInBrowser()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
Rimani nel browser
</button>
</div>
</ng-container>
<!-- Se l'apertura automatica/manuale fallisce (PWA probabilmente non installata) -->
<ng-container *ngIf="redirectFailed()">
<!-- iOS Guide -->
<div *ngIf="settingsService.isIos()" style="text-align: left;">
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; text-align: center; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2>
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; text-align: center; margin-bottom: 20px; -webkit-font-smoothing: antialiased;">
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
</p>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
</div>
</div>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> .
</div>
</div>
</div>
<button (click)="stayInBrowser()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); width: 100%; text-align: center;">
Continua nel browser
</button>
</div>
<!-- Android / Desktop -->
<div *ngIf="!settingsService.isIos()" style="text-align: center;">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser ed usarla offline!
</p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button *ngIf="settingsService.showInstallButton() || settingsService.deferredPrompt()" (click)="triggerInstallFromRedirect()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
INSTALLA APP
</button>
<button (click)="stayInBrowser()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
Continua nel browser
</button>
</div>
</div>
</ng-container>
</div>
</div>
<!-- PWA Install Overlay -->
<div *ngIf="showInstallOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
<!-- Android Install Prompt -->
<div *ngIf="settingsService.isAndroid()"
style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser, aprirla all'istante ed usarla anche offline in chiesa!
</p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="triggerInstall()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
INSTALLA APP
</button>
<button (click)="closeInstallOverlay()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
Continua nel browser
</button>
</div>
</div>
<!-- iOS Install Guide -->
<div *ngIf="settingsService.isIos()"
style="padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="text-align: center; margin-bottom: 20px;">
<div style="margin-bottom: 15px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 70px; height: 70px; border-radius: 18px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2>
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; -webkit-font-smoothing: antialiased;">
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
</p>
</div>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
</div>
</div>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> .
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; width: 100%;">
<button (click)="closeInstallOverlay()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); text-align: center;">
Ho capito, continua
</button>
</div>
</div>
<!-- Desktop / Other Install Prompt -->
<div *ngIf="!settingsService.isAndroid() && !settingsService.isIos()"
style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Installa l'applicazione sul tuo computer per evitare problemi di cache del browser ed usarla offline!
</p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="triggerInstall()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
INSTALLA APP
</button>
<button (click)="closeInstallOverlay()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
Continua nel browser
</button>
</div>
</div>
</div>
</ion-app>
+405 -47
View File
@@ -1,7 +1,12 @@
import { Component, inject, ApplicationRef } from '@angular/core';
import { Component, inject, OnInit, signal, effect } from '@angular/core';
import { ThemeService } from './services/theme.service';
import { CantiService } from './services/canti.service';
import { SettingsService } from './services/settings.service';
import { VERSION } from './version';
import { Router, ActivatedRoute } from '@angular/router';
import { ToastController } from '@ionic/angular';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators';
import { concat, interval, fromEvent } from 'rxjs';
@Component({
selector: 'app-root',
@@ -9,61 +14,414 @@ import { concat, interval, fromEvent } from 'rxjs';
styleUrls: ['app.component.scss'],
standalone: false,
})
export class AppComponent {
export class AppComponent implements OnInit {
private themeService = inject(ThemeService); // Ensures theme is initialized at boot
public cantiService = inject(CantiService);
public settingsService = inject(SettingsService);
public version = VERSION;
private router = inject(Router);
private route = inject(ActivatedRoute);
private toastCtrl = inject(ToastController);
private swUpdate = inject(SwUpdate);
private appRef = inject(ApplicationRef);
public showRedirectOverlay = signal<boolean>(false);
public showInstallOverlay = signal<boolean>(false);
public redirectFailed = signal<boolean>(false);
public protocolLink = '';
constructor() {
this.setupUpdates();
// Gli aggiornamenti automatici e periodici sono stati rimossi.
// L'aggiornamento viene gestito esclusivamente in modo manuale
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
effect(() => {
this.checkLoaderDismissal();
});
}
private setupUpdates() {
checkLoaderDismissal() {
const ready = this.settingsService.isVersionCheckComplete() && this.cantiService.firstLoadCompleted();
if (!ready) {
return;
}
// Se l'overlay di redirect o di installazione è mostrato, NON nascondiamo il loader iniziale
// per rimanere nella welcome page mentre l'utente sceglie.
if (this.showRedirectOverlay() || this.showInstallOverlay()) {
return;
}
// Altrimenti, nascondiamo il loader per far entrare l'utente nell'app
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
}
async ngOnInit() {
// Add global horizontal scroll support for wheel on horizontal containers
window.addEventListener('wheel', (event: WheelEvent) => {
if (Math.abs(event.deltaY) > 0 && Math.abs(event.deltaX) === 0) {
const path = event.composedPath();
for (const target of path) {
if (target instanceof HTMLElement) {
const style = window.getComputedStyle(target);
const isHorizontalScroll =
(style.overflowX === 'auto' || style.overflowX === 'scroll') &&
target.scrollWidth > target.clientWidth;
if (isHorizontalScroll) {
target.scrollLeft += event.deltaY;
event.preventDefault();
break;
}
}
}
}
}, { passive: false });
// 1. Allineamento istantaneo alla versione remota
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
phase: 'Fase: Verifica Versione',
desc: 'Verifica della versione più recente in corso...'
});
}
const updated = await this.checkVersionSync();
if (updated) {
return; // Reloading, skip further setup
}
// Set signal indicating startup version check is complete
this.settingsService.isVersionCheckComplete.set(true);
this.route.queryParams.subscribe(params => {
const protocolUrl = params['url'];
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
try {
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
const urlObj = new URL(cleanUrl);
let targetPath = urlObj.pathname;
if (targetPath === '/open' || targetPath === '//open') {
targetPath = '/';
} else if (targetPath.startsWith('/open/')) {
targetPath = targetPath.substring(5);
}
const queryParams: any = {};
urlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
} catch (e) {
console.error('Failed to parse protocol url:', protocolUrl, e);
}
}
});
this.checkAndRedirectToPwa();
}
async checkVersionSync(): Promise<boolean> {
// Controlla SEMPRE version.json per primo — è il modo più affidabile per
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
// Su mobile, checkForUpdate() può essere lento o inaffidabile.
try {
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
const response = await Promise.race([
fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' }),
new Promise<Response | null>((resolve) => setTimeout(() => resolve(null), 5000))
]);
if (response && response.ok) {
const data = await response.json();
if (data && data.version && data.version !== VERSION) {
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Forza aggiornamento...`);
const overlay = showFullscreenUpdateOverlay();
// Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW)
if (this.swUpdate.isEnabled) {
// Wait for the application to stabilize before running update checks or starting intervals
this.appRef.isStable.pipe(
filter(stable => stable),
try {
const hasSwUpdate = await Promise.race([
this.swUpdate.checkForUpdate(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]);
if (hasSwUpdate) {
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...');
await Promise.race([
this.swUpdate.activateUpdate(),
new Promise<void>((resolve) => setTimeout(resolve, 5000))
]);
}
} catch (e) {
console.warn('[PWA-Update] SwUpdate durante mismatch fallito:', e);
}
}
// Aggiorna anche la registrazione SW direttamente (doppia sicurezza)
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.update();
} catch (e) {
console.warn('[PWA-Update] SW registration.update fallito:', e);
}
}
// Disattiva service worker attivi per forzare il refresh completo
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
}
// Cancella le cache del browser
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
overlay.finish();
// Ricarica con parametro cache-busting per forzare l'allineamento remoto
const url = new URL(window.location.href);
url.searchParams.set('update_cb', Date.now().toString());
window.location.replace(url.toString());
return true;
} else {
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
}
}
} catch (e) {
console.warn('[PWA-Update] version.json check fallito:', e);
}
// 2. Fallback: Prova SwUpdate nel caso in cui il controllo version.json sia fallito o sia stato servito dalla cache
if (this.swUpdate.isEnabled) {
try {
console.log('[PWA-Update] Verifica aggiornamenti via SwUpdate all\'avvio...');
let activated = false;
let overlay: any = null;
const activateAndReload = async () => {
if (activated) return;
activated = true;
try {
await this.swUpdate.activateUpdate();
} catch (e) {
console.warn('[PWA-Update] activateUpdate fallito all\'avvio:', e);
}
// Deregistra i vecchi SW e cancella le cache per un ricaricamento pulito
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
}
}
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
if (overlay) overlay.finish();
setTimeout(() => {
const url = new URL(window.location.href);
url.searchParams.set('update_cb', Date.now().toString());
window.location.replace(url.toString());
}, 600);
};
// Sottoscrivi PRIMA di verificare l'aggiornamento per evitare race condition
const sub = this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
).subscribe(() => {
console.log('[PWA-Update] App is stable. Initializing update checks...');
// 1. Check for updates immediately
this.swUpdate.checkForUpdate().catch(err => {
console.warn('[PWA-Update] Failed immediate startup update check:', err);
});
// 2. Periodic check in background every 30 seconds
const every30Seconds$ = interval(30 * 1000);
every30Seconds$.subscribe(async () => {
console.log('[PWA-Update] Periodic check for updates (every 30s)...');
try {
await this.swUpdate.checkForUpdate();
} catch (err) {
console.warn('[PWA-Update] Failed periodic update check:', err);
}
});
});
// 3. Check for updates when the app is resumed/focused
fromEvent(document, 'visibilitychange')
.pipe(filter(() => document.visibilityState === 'visible'))
.subscribe(async () => {
console.log('[PWA-Update] App resumed, checking for PWA updates...');
try {
await this.swUpdate.checkForUpdate();
} catch (err) {
console.warn('[PWA-Update] Failed visible resume update check:', err);
}
});
// 4. Activate update and reload when a new version is ready
this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
)
.subscribe(() => {
console.log('[PWA-Update] New version ready! Activating and reloading...');
this.swUpdate.activateUpdate().then(() => {
window.location.reload();
console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio');
activateAndReload();
});
// Concedi fino a 8 secondi al controllo SW — le connessioni mobili possono essere lente
const hasUpdate = await Promise.race([
this.swUpdate.checkForUpdate(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]);
if (hasUpdate) {
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...');
overlay = showFullscreenUpdateOverlay();
// Timeout di sicurezza di 25 secondi: se VERSION_READY non arriva, attiva comunque
setTimeout(() => {
console.log('[PWA-Update] Safety timeout raggiunto all\'avvio, procedo...');
sub.unsubscribe();
activateAndReload();
}, 25000);
return true; // Attendi il ricaricamento
} else {
sub.unsubscribe();
}
} catch (err) {
console.warn('[PWA-Update] Controllo SwUpdate fallito all\'avvio:', err);
}
}
return false;
}
async checkAndRedirectToPwa() {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) {
localStorage.setItem('pwa-installed', 'true');
return;
}
const search = window.location.search;
const path = window.location.pathname;
this.protocolLink = `web+canti://open${path}${search}`;
// Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo
let isInstalled = localStorage.getItem('pwa-installed') === 'true';
if (!isInstalled && 'getInstalledRelatedApps' in navigator) {
try {
const relatedApps = await (navigator as any).getInstalledRelatedApps();
isInstalled = relatedApps.length > 0;
if (isInstalled) {
localStorage.setItem('pwa-installed', 'true');
}
} catch (e) {
console.warn('Failed to check installed apps:', e);
}
}
if (isInstalled) {
const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true';
if (!skipRedirect) {
this.showRedirectOverlay.set(true);
this.redirectFailed.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ isRedirect: true });
}
// Tentiamo il reindirizzamento automatico
setTimeout(() => {
window.location.href = this.protocolLink;
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
setTimeout(() => {
if (this.showRedirectOverlay()) {
this.redirectFailed.set(true);
localStorage.setItem('pwa-installed', 'false');
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
}
}, 2000);
}, 800);
}
} else {
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
const isMobile = this.settingsService.isAndroid() || this.settingsService.isIos();
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
this.showInstallOverlay.set(true);
}
}
}
openPwaManual() {
window.location.href = this.protocolLink;
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({ isRedirect: true });
}
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
setTimeout(() => {
if (this.showRedirectOverlay()) {
this.redirectFailed.set(true);
localStorage.setItem('pwa-installed', 'false');
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
}
}, 2000);
}
stayInBrowser() {
sessionStorage.setItem('skip-pwa-redirect', 'true');
this.showRedirectOverlay.set(false);
}
closeInstallOverlay() {
sessionStorage.setItem('skip-pwa-install', 'true');
this.showInstallOverlay.set(false);
}
async triggerInstall() {
await this.settingsService.installPwa();
this.closeInstallOverlay();
}
async triggerInstallFromRedirect() {
await this.settingsService.installPwa();
sessionStorage.setItem('skip-pwa-redirect', 'true');
this.showRedirectOverlay.set(false);
}
}
export function showFullscreenUpdateOverlay() {
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Download aggiornamento',
phase: 'Fase: Download',
desc: 'Scaricamento della nuova versione...',
percent: 0
});
}
// Fetch remote version to display the version being downloaded
fetch(`/version.json?cb=${Date.now()}`)
.then(res => {
if (res.ok) return res.json();
throw new Error('Fallback');
})
.then(data => {
if (data && data.version && (window as any).PwaLoader) {
(window as any).PwaLoader.update({
version: 'Versione ' + data.version
});
}
})
.catch(() => {});
let percent = 0;
const interval = setInterval(() => {
if (percent < 95) {
percent += Math.floor(Math.random() * 5) + 2;
if (percent > 95) percent = 95;
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent });
}
}
}, 150);
return {
finish: () => {
clearInterval(interval);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: 100 });
}
}
};
}
+6 -2
View File
@@ -1,7 +1,7 @@
import { NgModule, isDevMode } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { HttpClientModule } from '@angular/common/http';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { IonicStorageModule } from '@ionic/storage-angular';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
@@ -9,6 +9,7 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor';
@NgModule({
declarations: [AppComponent],
@@ -24,7 +25,10 @@ import { ServiceWorkerModule } from '@angular/service-worker';
registrationStrategy: 'registerImmediately'
})
],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{ provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true }
],
bootstrap: [AppComponent],
})
export class AppModule {}
@@ -0,0 +1,41 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">QR Code di Ripristino</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="bg-gradient ion-padding">
<div class="qr-container">
<p class="description outfit-font">
Salva questo QR Code (fai uno screenshot o scaricalo) per ripristinare il tuo account e le tue comunità se cambi dispositivo o reinstalli l'applicazione.
</p>
<div class="qr-card glass">
<div class="qr-wrapper" *ngIf="qrCodeUrl">
<img [src]="qrCodeUrl" alt="QR Code di Ripristino" class="qr-image" />
</div>
<div class="uuid-box">
<span class="uuid-label outfit-font">Codice Identificativo:</span>
<span class="uuid-text select-all outfit-font">{{ userUuid }}</span>
</div>
</div>
<div class="actions-wrapper">
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
<ion-icon name="copy-outline" slot="start"></ion-icon>
Copia Codice Testuale
</ion-button>
<a *ngIf="qrCodeUrl" [href]="qrCodeUrl" download="ripristino-identita-canti.png" class="download-btn outfit-font">
<ion-icon name="download-outline"></ion-icon>
Scarica Immagine QR
</a>
</div>
</div>
</ion-content>
@@ -0,0 +1,121 @@
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 8px;
text-align: center;
min-height: 100%;
}
.description {
font-size: 0.95rem;
line-height: 1.5;
color: var(--ion-text-color);
opacity: 0.9;
margin-bottom: 24px;
max-width: 320px;
}
.qr-card {
padding: 24px;
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 24px;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
}
.qr-wrapper {
background: white;
padding: 12px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 220px;
height: 220px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.uuid-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.uuid-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ion-color-secondary);
font-weight: 600;
}
.uuid-text {
font-size: 0.8rem;
color: var(--ion-text-color);
word-break: break-all;
opacity: 0.85;
font-family: monospace;
user-select: all;
background: rgba(var(--ion-text-color-rgb, 255, 255, 255), 0.04);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.actions-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 340px;
}
.action-btn {
margin: 0;
--border-radius: 14px;
--box-shadow: 0 4px 16px rgba(var(--ion-color-secondary-rgb), 0.2);
font-weight: 600;
height: 48px;
}
.download-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
background: rgba(255, 255, 255, 0.06);
color: var(--ion-text-color);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 12px;
border-radius: 14px;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s ease;
ion-icon {
font-size: 1.2rem;
color: var(--ion-color-secondary);
}
&:active {
background: rgba(255, 255, 255, 0.12);
}
}
@@ -0,0 +1,58 @@
import { Component, OnInit, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-identity-qr-modal',
templateUrl: './identity-qr-modal.component.html',
styleUrls: ['./identity-qr-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule]
})
export class IdentityQrModalComponent implements OnInit {
private modalCtrl = inject(ModalController);
private toastCtrl = inject(ToastController);
@Input() userUuid!: string;
public qrCodeUrl: string = '';
ngOnInit() {
this.generateQr();
}
async generateQr() {
try {
this.qrCodeUrl = await QRCode.toDataURL(this.userUuid, {
errorCorrectionLevel: 'H',
margin: 2,
width: 400,
color: {
dark: '#1e293b', // Slate 800 for premium dark aesthetic contrast
light: '#ffffff'
}
});
} catch (err) {
console.error('Failed to generate QR Code:', err);
}
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.userUuid);
const toast = await this.toastCtrl.create({
message: 'Codice copiato negli appunti!',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
} catch (err) {
console.error('Failed to copy text:', err);
}
}
dismiss() {
this.modalCtrl.dismiss();
}
}
@@ -11,11 +11,30 @@
<div class="scanner-container">
<zxing-scanner
[formats]="allowedFormats"
[device]="currentDevice"
(camerasFound)="onCamerasFound($event)"
(scanSuccess)="onCodeResult($event)">
</zxing-scanner>
<div class="scan-overlay">
<div class="scan-frame"></div>
<p class="scan-text">Inquadra il QR Code della tua parrocchia</p>
</div>
<!-- Pulsante premium per cambiare fotocamera -->
<div class="camera-toggle-container" *ngIf="availableDevices.length > 1">
<button (click)="toggleCamera()" class="camera-toggle-btn">
<ion-icon name="camera-reverse-outline"></ion-icon>
<span>Cambia fotocamera</span>
</button>
</div>
<!-- Messaggio di aiuto per utenti iPad in modalità desktop -->
<div class="ipad-warning-container" *ngIf="showIpadWarning">
<ion-icon name="information-circle-outline"></ion-icon>
<p>
Su iPad, se non vedi lo switch fotocamera, tocca l'icona <strong>"aA"</strong> in alto nella barra di Safari e seleziona <strong>"Richiedi sito mobile"</strong>.
</p>
</div>
</div>
</ion-content>
@@ -55,6 +55,91 @@
padding: 0 40px;
}
}
.camera-toggle-container {
position: absolute;
bottom: 50px;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.camera-toggle-btn {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.25);
color: white;
padding: 12px 24px;
border-radius: 30px;
font-family: 'Outfit', sans-serif;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
cursor: pointer;
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
outline: none;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
&:active {
background: rgba(255, 255, 255, 0.35);
transform: scale(0.96);
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.37);
}
ion-icon {
font-size: 20px;
}
}
.ipad-warning-container {
position: absolute;
bottom: 40px;
left: 24px;
right: 24px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 14px;
padding: 14px 18px;
display: flex;
align-items: flex-start;
gap: 12px;
color: white;
z-index: 10;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
ion-icon {
font-size: 24px;
color: var(--ion-color-secondary);
flex-shrink: 0;
margin-top: 2px;
}
p {
margin: 0;
font-family: 'Outfit', sans-serif;
font-size: 0.82rem;
line-height: 1.45;
font-weight: 500;
text-align: left;
strong {
color: var(--ion-color-secondary);
font-weight: 700;
}
}
}
}
@keyframes scan {
@@ -16,6 +16,39 @@ export class QrScannerComponent {
private modalCtrl = inject(ModalController);
public allowedFormats = [BarcodeFormat.QR_CODE];
public availableDevices: MediaDeviceInfo[] = [];
public currentDevice: MediaDeviceInfo | undefined = undefined;
onCamerasFound(devices: MediaDeviceInfo[]) {
this.availableDevices = devices;
if (devices && devices.length > 0) {
// Cerca la fotocamera posteriore (etichette contenenti 'back', 'rear', 'environment', 'posteriore')
const backCamera = devices.find(d => {
const label = d.label.toLowerCase();
return label.includes('back') ||
label.includes('rear') ||
label.includes('environment') ||
label.includes('posteriore');
});
this.currentDevice = backCamera || devices[0];
}
}
toggleCamera() {
if (this.availableDevices.length <= 1) return;
const currentIndex = this.availableDevices.findIndex(d => d.deviceId === this.currentDevice?.deviceId);
const nextIndex = (currentIndex + 1) % this.availableDevices.length;
this.currentDevice = this.availableDevices[nextIndex];
}
get showIpadWarning(): boolean {
const isIPadDesktop =
/Macintosh/.test(navigator.userAgent) &&
navigator.maxTouchPoints !== undefined &&
navigator.maxTouchPoints > 1;
return isIPadDesktop && this.availableDevices.length <= 1;
}
onCodeResult(result: string) {
if (result) {
this.modalCtrl.dismiss(result);
+72 -85
View File
@@ -5,7 +5,12 @@
<img src="assets/icon/favicon.png" class="header-logo">
<div class="header-text-group">
<span class="app-name">{{ appName }}</span>
<span class="version-badge">v{{ version }}</span>
<span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span>
<span *ngIf="hasUpdateAvailable()" style="display: inline-flex; align-items: center; justify-content: center; padding: 4px; margin-left: 2px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 24px; height: 24px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3);">
<ion-icon name="refresh-outline" style="font-size: 1.15rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
</span>
</div>
</div>
</ion-title>
@@ -14,9 +19,6 @@
<ion-button (click)="importPlaylist()" class="add-btn">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button routerLink="/propose-canto" class="add-btn" *ngIf="!playlistService.selectionMode() && settingsService.showEditor()">
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
</ion-button>
<ion-button routerLink="/settings" class="settings-btn">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button>
@@ -43,6 +45,18 @@
{{ filteredCanti().length }}
</div>
<div class="filter-buttons">
<ion-button
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
size="small"
(click)="toggleFilterType('playlist')"
class="filter-chip">
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<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'"
@@ -54,25 +68,16 @@
<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'"
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
size="small"
(click)="toggleOnlyMine()"
color="secondary"
(click)="toggleFilterType('lista_completa')"
class="filter-chip">
Miei
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon>
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
@@ -82,7 +87,7 @@
size="small"
(click)="toggleFilterType('liturgico')"
class="filter-chip">
{{ selectedLiturgico() !== null ? getSelectedLiturgicoLabel() : 'Liturgia' }}
{{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
@@ -94,7 +99,7 @@
size="small"
(click)="toggleFilterType('tematico')"
class="filter-chip">
{{ selectedTematico() !== null ? getSelectedTematicoLabel() : 'Periodo' }}
{{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
@@ -112,19 +117,6 @@
</span>
</ion-button>
<ion-button
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
size="small"
(click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()"
class="filter-chip">
{{ playlistService.activeListName() !== null ? playlistService.activeListName() : 'Playlist' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length > 0"></ion-icon>
<ion-icon slot="end" name="add-circle-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length === 0"></ion-icon>
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="showTopTen() ? 'solid' : 'outline'"
size="small"
@@ -142,36 +134,60 @@
<!-- Category Scroll (Dropdown style) -->
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="activeFilterType() || playlistService.selectionMode() || (playlistService.activeListName() && !playlistService.selectionMode())">
<div class="momento-scroll">
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="shouldShowSubSectionToolbar()">
<div class="momento-scroll" *ngIf="activeFilterType() !== 'playlist' || playlistService.allPlaylists().length > 0 || playlistService.selectionMode()">
<!-- Selection Mode Actions -->
<div class="selection-pill glass" *ngIf="playlistService.selectionMode()">
<span class="selection-count">{{ playlistService.selectedIds().size }}</span>
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn">
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
<span class="selection-count">{{ reorderList().length }}</span>
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn" *ngIf="reorderList().length > 0">
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="finishSelection()" [disabled]="playlistService.selectedIds().size === 0" class="mini-action-btn">
<ion-button fill="clear" color="secondary" (click)="finishSelection()" [disabled]="reorderList().length === 0" class="mini-action-btn" *ngIf="reorderList().length > 0">
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="cancelSelection()" class="mini-action-btn">
<ion-button fill="clear" color="danger" (click)="cancelSelection()" class="mini-action-btn" *ngIf="reorderList().length > 0">
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
</ion-button>
</div>
<!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist()">
<div class="selection-pill glass" *ngIf="activeFilterType() === 'playlist' && playlistService.activePlaylistId() && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button>
</div>
<!-- Filter chips -->
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
<div
class="momento-chip glass"
[class.active]="showValidati()"
(click)="toggleValidati()">
Validati
</div>
<div
class="momento-chip glass"
[class.active]="showNonValidati()"
(click)="toggleNonValidati()"
style="display: inline-flex; align-items: center; gap: 6px;">
<span>Non Validati</span>
<ion-icon
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
name="add-outline"
(click)="$event.stopPropagation()"
routerLink="/propose-canto"
class="non-validati-add-icon">
</ion-icon>
</div>
</ng-container>
<ng-container *ngIf="activeFilterType() !== 'lista_completa'">
<div
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
class="momento-chip glass"
@@ -181,6 +197,14 @@
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
</div>
</ng-container>
</div>
<!-- Instructions when playlist is empty -->
<div class="empty-playlist-container" *ngIf="activeFilterType() === 'playlist' && playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()">
<p class="empty-playlist-instruction outfit-font">
Per creare una playlist, seleziona il nr del canto che vuoi inserire nella playlist, riordinali e salvala con nome
</p>
</div>
</ion-toolbar>
</ion-header>
@@ -192,28 +216,6 @@
(click)="onInteraction()">
<div class="ion-padding no-padding-top">
<!-- Premium PWA Android Install Banner -->
<div class="pwa-android-banner glass ion-margin-bottom" *ngIf="showAndroidBanner()">
<div class="banner-inner">
<div class="banner-icon-wrapper">
<ion-icon name="cloud-download-outline" class="banner-icon"></ion-icon>
</div>
<div class="banner-text-wrapper">
<h3 class="banner-title outfit-font">Installa la nostra App!</h3>
<p class="banner-subtitle outfit-font">
Accedi a tutti i canti all'istante, anche offline, direttamente dalla tua home screen.
</p>
</div>
<div class="banner-actions">
<ion-button fill="solid" size="small" color="secondary" class="install-action-btn outfit-font" (click)="installAndroidPwa()">
Installa
</ion-button>
<ion-button fill="clear" size="small" class="dismiss-action-btn" (click)="dismissAndroidBanner($event)">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
</div>
</div>
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
<div class="loading-wrapper">
@@ -281,9 +283,9 @@
<div class="item-wrapper">
<!-- Selection Area -->
<div class="selection-column" (click)="playlistService.toggleSongSelection(canto.id); $event.stopPropagation()">
<div class="selection-column" (click)="toggleSongSelection(canto.id); $event.stopPropagation()">
<span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)">
{{ canto.id.startsWith('my_') ? 'M' : canto.id_canti }}
{{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
</span>
</div>
@@ -294,7 +296,8 @@
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary); display: flex; align-items: center; flex-wrap: wrap; gap: 4px;">
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span>
<span>{{ canto.titolo }}</span>
<span *ngIf="canto.nonValidato" class="non-validato-badge">Non Validato</span>
<span *ngIf="canto.nonValidato && !canto.id.startsWith('my_')" class="non-validato-badge remote-badge">Remoto</span>
<span *ngIf="canto.id.startsWith('my_')" class="non-validato-badge mio-badge">Mio</span>
</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>
@@ -418,20 +421,4 @@
</ion-toolbar>
</ion-footer>
<!-- Premium PWA iOS Tooltip -->
<div class="pwa-ios-tooltip-wrapper" [class.has-player]="youtubePlayerService.currentCantoId()" *ngIf="showIosTooltip()">
<div class="pwa-ios-tooltip glass">
<div class="tooltip-header">
<span class="tooltip-title outfit-font">Installa su iPhone</span>
<ion-button fill="clear" size="small" class="dismiss-btn" (click)="dismissIosTooltip($event)">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
<div class="tooltip-body">
<p class="tooltip-text outfit-font">
Tocca il pulsante Condividi <span class="safari-icon-inline"><ion-icon name="share-outline"></ion-icon></span> in basso e seleziona <strong>"Aggiungi a schermata Home"</strong>.
</p>
</div>
<div class="tooltip-arrow"></div>
</div>
</div>
+67 -2
View File
@@ -174,7 +174,7 @@ ion-title {
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 16px 0 4px 24px; // Reduced padding to bring search bar closer
padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room
}
.header-logo {
@@ -318,7 +318,7 @@ ion-title {
.search-wrapper-group {
display: flex;
flex-direction: column;
padding: 0 16px 8px 16px;
padding: 6px 16px 8px 16px; // Added slight top padding for search bar breathing room
gap: 8px;
}
@@ -368,6 +368,24 @@ ion-title {
}
}
.non-validati-add-icon {
font-size: 1.1rem;
color: #000;
font-weight: bold;
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
padding: 2px;
display: inline-block;
vertical-align: middle;
}
:host-context(body.high-contrast) {
.non-validati-add-icon {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.2) !important;
}
}
// Custom Item Layout
.custom-item {
--padding-start: 16px;
@@ -946,12 +964,24 @@ ion-title {
margin-left: 6px;
display: inline-block;
vertical-align: middle;
&.mio-badge {
background: rgba(var(--ion-color-secondary-rgb), 0.15);
color: var(--ion-color-secondary);
border-color: rgba(var(--ion-color-secondary-rgb), 0.35);
}
}
:host-context(body.high-contrast) .non-validato-badge {
background: rgba(231, 76, 60, 0.1) !important;
color: #c0392b !important;
border-color: #c0392b !important;
&.mio-badge {
background: rgba(var(--ion-color-secondary-rgb), 0.1) !important;
color: var(--ion-color-secondary-shade, #007bb6) !important;
border-color: var(--ion-color-secondary-shade, #007bb6) !important;
}
}
/* ==========================================================================
@@ -1273,3 +1303,38 @@ ion-title {
}
}
}
.empty-playlist-container {
padding: 12px 16px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.empty-playlist-instruction {
margin: 0;
font-size: 0.85rem;
line-height: 1.4;
color: rgba(255, 255, 255, 0.8);
text-align: left;
background: rgba(var(--ion-color-secondary-rgb), 0.08);
border: 1px dashed rgba(var(--ion-color-secondary-rgb), 0.35);
padding: 12px 16px;
border-radius: 12px;
max-width: 480px;
width: 100%;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:host-context(body.high-contrast) {
.empty-playlist-instruction {
color: #000000 !important;
background: rgba(var(--ion-color-secondary-rgb), 0.12) !important;
border-color: var(--ion-color-secondary) !important;
box-shadow: none !important;
}
}
+867 -155
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable()
export class ApiAuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.url.includes('api.canticristiani.it')) {
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
const authReq = req.clone({
setHeaders: {
Authorization: authHeader
}
});
return next.handle(authReq);
}
return next.handle(req);
}
}
+3 -3
View File
@@ -8,7 +8,7 @@
<!-- Previous line -->
<div *ngIf="displayLines().prev" class="prev-line">
<ng-container *ngIf="showChords(); else prevText">
<span *ngFor="let seg of displayLines().prev!.segments" class="chord-segment">
<span *ngFor="let seg of displayLines().prev!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().prev!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
@@ -19,7 +19,7 @@
<!-- Active line -->
<div *ngIf="displayLines().current" class="active-line outfit-font">
<ng-container *ngIf="showChords(); else currentText">
<span *ngFor="let seg of displayLines().current!.segments" class="chord-segment">
<span *ngFor="let seg of displayLines().current!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().current!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
@@ -30,7 +30,7 @@
<!-- Next line -->
<div *ngIf="displayLines().next" class="next-line">
<ng-container *ngIf="showChords(); else nextText">
<span *ngFor="let seg of displayLines().next!.segments" class="chord-segment">
<span *ngFor="let seg of displayLines().next!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().next!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
+3
View File
@@ -74,6 +74,9 @@
.seg-text {
white-space: pre-wrap;
&::after {
content: '\200b';
}
}
.footer-info {
+36 -4
View File
@@ -16,14 +16,42 @@ export class DisplayPage implements OnInit, OnDestroy {
public showChords = signal<boolean>(false);
public fontSize = signal<number>(1.0);
public currentLineIndex = signal<number>(0);
public transposeAmount = signal<number>(0);
public parsedSections = computed<ParsedSection[]>(() => {
const c = this.canto();
if (!c) return [];
if (this.showChords() && c.accordi) {
return this.lyricsParser.parseAccordi(c.accordi);
let sections: ParsedSection[];
const hasChordsInText = !c.accordi && c.testo?.includes('[');
if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) {
sections = this.lyricsParser.parseAccordi(c.accordi || c.testo);
} else {
sections = this.lyricsParser.parseText(c.testo);
}
return this.lyricsParser.parseText(c.testo);
if (sections.length === 0 && c.testo) {
const fallbackLines = c.testo.split('\n')
.filter(l => l.trim().length > 0)
.map(l => ({
text: l.trim(),
segments: [{ text: l.trim() }]
}));
if (fallbackLines.length > 0) {
sections = [{
type: 'verse',
lines: fallbackLines
}];
}
}
if (this.showChords()) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount());
}
return sections;
});
/** Get the current line text and surrounding lines from flat index */
@@ -54,7 +82,7 @@ export class DisplayPage implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private cantiService = inject(CantiService);
private lyricsParser = inject(LyricsParserService);
public lyricsParser = inject(LyricsParserService);
private comunitaService = inject(ComunitaService);
constructor() {
@@ -94,6 +122,7 @@ export class DisplayPage implements OnInit, OnDestroy {
}
if (event.data.type === 'SYNC_CANTO') {
this.activeSongId.set(event.data.id);
this.transposeAmount.set(0);
}
if (event.data.type === 'SYNC_CHORDS') {
this.showChords.set(event.data.showChords);
@@ -101,6 +130,9 @@ export class DisplayPage implements OnInit, OnDestroy {
if (event.data.type === 'SYNC_FONT') {
this.fontSize.set(event.data.fontSize);
}
if (event.data.type === 'SYNC_TRANSPOSE') {
this.transposeAmount.set(event.data.amount);
}
};
}
+122 -110
View File
@@ -1,10 +1,30 @@
<ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar class="bg-gradient top-toolbar">
<ion-buttons slot="start">
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-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?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span>
</span>
</div>
</ion-title>
<ion-buttons slot="end">
<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-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" 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'"
@@ -12,21 +32,6 @@
</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?.startsWith('my_') ? 'M' : canto()?.id_canti }}
</span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span>
</span>
<span *ngIf="canto()?.nonValidato" class="non-validato-badge">Non Validato</span>
</div>
</ion-title>
<ion-buttons slot="start">
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons>
</ion-toolbar>
<!-- Audio Toolbar Removed -->
@@ -36,58 +41,66 @@
<div class="lyrics-container"
[style.fontSize.rem]="fontSize()"
[class.full-screen-container]="settingsService.fullscreenMode()"
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()">
<!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container">
<!-- Autoscroll group in Landscape Side -->
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()" style="gap: 4px; padding: 4px 0; background: rgba(255,255,255,0.05); border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); width: 44px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10" style="height: 32px; margin: 0;">
<ion-icon name="add" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
<div (click)="toggleAutoscroll()" style="cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'" style="font-size: 1.4rem;"></ion-icon>
<span style="font-size: 0.65rem; font-weight: 700; color: var(--ion-color-secondary);">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1" style="height: 32px; margin: 0;">
<ion-icon name="remove" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
</div>
<div class="side-group equidistant-group">
<!-- Navigation -->
<ion-button fill="clear" (click)="restart()">
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
<!-- Navigation Group -->
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
prv
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon name="chevron-up" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon name="chevron-down" color="secondary"></ion-icon>
</ion-button>
<!-- Zoom -->
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= 5.0">
<ion-icon name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= 0.6">
<ion-icon name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
<!-- Karaoke Toggle (Moved down) -->
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'">
<ion-icon [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
</ion-button>
<!-- Youtube Link -->
<ion-button *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" (click)="openYoutube()">
<ion-icon name="logo-youtube" color="danger"></ion-icon>
<ion-icon slot="icon-only" name="chevron-up" color="secondary"></ion-icon>
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-down" color="secondary"></ion-icon>
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="landscape-playlist-btn next-btn">
nxt
</ion-button>
</div>
<!-- Zoom Group -->
<div class="side-group">
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
<div class="side-indicator" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">
<ion-icon name="search-outline" color="secondary" style="font-size: 1.0rem;"></ion-icon>
<span class="side-val" style="font-size: 0.7rem; font-weight: 700; color: var(--ion-color-secondary);">{{ fontSize().toFixed(1) }}</span>
</div>
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Autoscroll Standard Group -->
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()">
<ion-button fill="clear" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="side-indicator" (click)="toggleAutoscroll()">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
<span class="side-val">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
</div>
</div>
@@ -107,8 +120,8 @@
[class.active]="isActiveLine(si, li)">
<!-- Chord mode: show chords above text -->
<ng-container *ngIf="showChords(); else textOnly">
<span *ngFor="let seg of line.segments" class="chord-segment">
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
@@ -120,20 +133,23 @@
</ng-template>
</div>
</div>
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima sullo schermo -->
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
</div>
</div>
</ion-content>
<ion-footer class="ion-no-border">
<!-- Audio Player Toolbar -->
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
<!-- Audio Player / Playlist Navigation Toolbar -->
<ion-toolbar class="global-player-toolbar glass">
<div class="player-content">
<div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button>
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
@@ -146,23 +162,20 @@
color="secondary"
class="global-range">
</ion-range>
</ng-container>
<ng-template #noAudioMsg>
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
Nessun audio disponibile
</div>
</ng-template>
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
</ion-button>
<ion-button fill="clear" color="medium" (click)="stopVideo($event)" class="close-btn">
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
<!-- Voice Activity Visualizer (Slim overlay) -->
<div class="transcript-area slim" *ngIf="settingsService.enableAcousticAutoscroll() && audioEngine.isListening()">
<div class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)"></div>
</div>
<ion-toolbar class="bg-gradient slim-toolbar">
<div class="slim-controls">
<!-- Autoscroll Standard in Portrait Footer -->
@@ -179,24 +192,16 @@
</ion-button>
</div>
<!-- Karaoke Toggle in Portrait -->
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" size="small" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'" class="mic-btn-portrait">
<ion-icon slot="icon-only" [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
<!-- Camera Head Gestures Toggle in Portrait -->
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button>
<!-- Transposition (Only in chords mode) -->
<div class="group" *ngIf="showChords()">
<ion-button fill="clear" size="small" (click)="transposeDown()">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="transpose-indicator">
<ion-icon name="musical-note" color="secondary"></ion-icon>
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
<div class="camera-indicator" *ngIf="enableCameraNavigation()" [class.tilted]="faceDetector.isTilted()">
<ion-icon name="person-outline"
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
</div>
<ion-button fill="clear" size="small" (click)="transposeUp()">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
<!-- Navigation -->
@@ -212,41 +217,48 @@
</ion-button>
</div>
<!-- Transposition (Only in chords mode) -->
<div class="group" *ngIf="showChords()">
<ion-button fill="clear" size="small" (click)="transposeDown()">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="transpose-indicator">
<ion-icon name="musical-note" color="secondary"></ion-icon>
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
</div>
<ion-button fill="clear" size="small" (click)="transposeUp()">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
<!-- Zoom -->
<div class="group">
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= 0.6">
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= 5.0">
<div class="zoom-indicator">
<ion-icon name="search-outline" color="secondary"></ion-icon>
<span class="val">{{ fontSize().toFixed(1) }}</span>
</div>
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Actions -->
<div class="group" *ngIf="connectivityService.isOnline()">
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()">
<!-- YouTube Link in Portrait Footer -->
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
<ion-button fill="clear" size="small" (click)="openYoutube()">
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
</ion-footer>
<!-- Vertical Sensitivity Slider Overlay -->
<div class="mic-sensitivity-overlay" *ngIf="settingsService.enableAcousticAutoscroll() && showSensitivitySlider() && audioEngine.isListening()">
<div class="slider-card glass">
<ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
<div class="slider-wrapper"
(touchstart)="handleSensitivityTouch($event)"
(touchmove)="handleSensitivityTouch($event)">
<div class="custom-vertical-slider">
<div class="slider-track"></div>
<div class="slider-fill" [style.height.%]="(audioEngine.sensitivity() - 50) * 2"></div>
<div class="slider-knob" [style.bottom.%]="(audioEngine.sensitivity() - 50) * 2"></div>
</div>
</div>
<span class="sensitivity-label outfit-font">{{ audioEngine.sensitivity() }}%</span>
</div>
</div>
<!-- Black Screen Overlay -->
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()"></div>
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
+386 -23
View File
@@ -15,7 +15,8 @@
// Force left alignment in Ionic toolbar
ion-title {
padding-inline: 8px;
padding-inline-start: 56px; // Clear the back button on iOS/Apple devices
padding-inline-end: 8px;
text-align: left !important;
}
@@ -171,6 +172,9 @@
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
min-height: 1.5em;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
&.active {
color: var(--ion-color-secondary);
@@ -186,6 +190,10 @@
vertical-align: bottom;
margin-right: 0.2em;
&.contiguous-next {
margin-right: 0 !important;
}
.chord {
font-size: 0.75em;
font-weight: 700;
@@ -196,6 +204,9 @@
.seg-text {
white-space: pre;
&::after {
content: '\200b';
}
}
}
@@ -253,13 +264,44 @@
ion-icon { font-size: 0.9rem; }
}
.zoom-indicator {
display: flex;
align-items: center;
gap: 2px;
font-size: 0.8rem;
font-weight: 700;
color: var(--ion-color-secondary);
ion-icon { font-size: 0.9rem; }
}
.camera-indicator {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.8rem;
font-weight: 700;
color: var(--ion-color-secondary);
padding: 0 4px;
transition: color 0.15s ease-out;
ion-icon {
font-size: 1.1rem;
transition: transform 0.15s ease-out;
display: inline-block;
}
&.tilted {
color: var(--ion-color-success, #2ed573);
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
}
}
}
// Transcript area
.transcript-area {
height: 4px;
width: 100%;
background: rgba(0,0,0,0.2);
.energy-bar {
height: 100%;
@@ -269,8 +311,13 @@
}
}
// Fullscreen and Orientation
@media (orientation: landscape) {
@keyframes pulse {
0% { transform: scale(0.9); opacity: 0.6; }
50% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.6; }
}
:host(.landscape-active) {
// Always hide footer in landscape as we have side controls
ion-footer {
display: none !important;
@@ -279,33 +326,30 @@
.lyrics-container {
height: 100%;
padding-top: 4px; // Minimized
padding-bottom: 8px !important;
padding-right: 90px !important; // More room for side controls and zoom
}
.lyrics-view {
max-width: 100% !important;
}
ion-content {
--offset-bottom: 0px !important;
}
}
ion-content.full-screen-content {
--offset-bottom: 0px !important;
@media (orientation: portrait) {
--offset-bottom: 48px !important; // Footer height
}
}
.landscape-side-controls {
display: none !important; // Strict hidden in portrait
@media (orientation: landscape) {
:host(.landscape-active) & {
display: flex !important;
flex-direction: column;
position: fixed;
right: 0;
top: 44px !important; // Align with header
top: 56px !important; // Align below header
bottom: 0;
width: 60px;
width: 50px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
@@ -317,8 +361,10 @@ ion-content.full-screen-content {
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 12px 0;
gap: 16px;
justify-content: flex-start;
align-items: center;
padding: 8px 0;
gap: 12px;
&::-webkit-scrollbar { display: none; }
}
@@ -326,23 +372,82 @@ ion-content.full-screen-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px; // Reduced for a more compact layout
padding-bottom: 20px;
gap: 8px; // Reduced for a more compact layout
padding-bottom: 12px;
background: rgba(255, 255, 255, 0.05);
margin: 0 2px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 6px;
ion-button {
--padding-start: 0;
--padding-end: 0;
margin: 0;
height: 48px;
ion-icon { font-size: 1.8rem; }
height: 38px;
width: 38px;
ion-icon { font-size: 1.5rem; }
}
}
.vertical-range-container {
height: 120px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 8px 0;
}
.vertical-range {
transform: rotate(-90deg);
width: 120px;
--bar-height: 4px;
--knob-size: 14px;
padding: 0;
margin: 0;
}
.side-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 700;
color: var(--ion-color-secondary);
padding: 4px 0;
cursor: pointer;
ion-icon {
font-size: 1.0rem;
}
&.tilted {
color: var(--ion-color-success, #2ed573);
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
}
}
.side-val {
font-size: 0.7rem;
font-weight: 700;
}
.side-divider {
display: none;
}
}
ion-content.full-screen-content {
--offset-bottom: 48px !important; // Footer height
}
:host(.landscape-active) ion-content.full-screen-content {
--offset-bottom: 0px !important;
}
.mic-sensitivity-overlay {
position: fixed;
right: 15px;
@@ -351,7 +456,7 @@ ion-content.full-screen-content {
z-index: 1000;
pointer-events: auto;
@media (orientation: landscape) {
:host(.landscape-active) & {
right: 80px; // Prossimo ai controlli laterali
}
@@ -450,6 +555,109 @@ ion-content.full-screen-content {
}
}
.voice-threshold-overlay {
position: fixed;
left: 15px;
top: 50%;
transform: translateY(-50%);
z-index: 1000;
pointer-events: auto;
.slider-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 6px 12px 6px;
background: rgba(18, 18, 18, 0.85) !important;
backdrop-filter: blur(25px);
border-radius: 24px;
border: 1px solid rgba(46, 213, 115, 0.4);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
gap: 8px;
animation: slideInLeft 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.close-slider-btn {
--padding-start: 0;
--padding-end: 0;
margin: 0;
height: 36px;
width: 36px;
--color: var(--ion-color-secondary);
ion-icon {
font-size: 1.5rem;
}
}
.slider-wrapper {
height: 180px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 10px 0;
}
.custom-vertical-slider {
position: relative;
width: 8px;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
.slider-track {
position: absolute;
width: 100%;
height: 100%;
}
.slider-fill {
position: absolute;
bottom: 0;
width: 100%;
background: linear-gradient(to top, #2ed573, #7bed9f);
border-radius: 4px;
box-shadow: 0 0 10px rgba(46, 213, 115, 0.3);
transition: height 0.05s linear;
}
.slider-knob {
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
width: 28px;
height: 28px;
background: #2ed573;
border-radius: 50%;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 0 15px rgba(46, 213, 115, 0.5);
border: 2px solid #fff;
transition: bottom 0.05s linear;
}
}
.sensitivity-label {
font-size: 0.8rem;
font-weight: 800;
color: #2ed573;
min-width: 40px;
text-align: center;
letter-spacing: 0.5px;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.floating-autoscroll-bar {
position: fixed;
bottom: 120px;
@@ -522,6 +730,27 @@ ion-content.full-screen-content {
}
:host-context(body.high-contrast) {
.slim-toolbar {
--background: #ffffff !important;
background: #ffffff !important;
border-top: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.landscape-side-controls {
--background: #ffffff !important;
background: #ffffff !important;
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.landscape-audio-player {
--background: #ffffff !important;
background: #ffffff !important;
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.slim-controls .group {
background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
@@ -553,3 +782,137 @@ ion-content.full-screen-content {
color: #c0392b !important;
border-color: #c0392b !important;
}
:host-context(.md) {
.top-toolbar ion-title {
padding-inline-start: 8px;
}
}
.autoscroll-sync-status {
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(212, 136, 0, 0.18) !important;
border: 1px solid rgba(253, 203, 110, 0.5);
padding: 6px 14px;
border-radius: 16px;
z-index: 999;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
animation: slideDownSync 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
pointer-events: none;
span {
font-size: 0.8rem;
font-weight: 700;
color: #fdcb6e;
letter-spacing: 0.3px;
}
.spin-animation {
animation: spinSync 2s linear infinite;
font-size: 1.1rem;
}
}
@keyframes spinSync {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes slideDownSync {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.camera-preview-floating {
position: fixed;
bottom: 60px;
right: 15px;
padding: 8px 14px;
border-radius: 20px;
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(18, 18, 18, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
:host(.landscape-active) & {
bottom: 15px;
right: 80px;
}
video {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.camera-preview-overlay {
display: flex;
justify-content: center;
align-items: center;
.angle-indicator {
font-size: 0.85rem;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
&.tilted {
color: #2ed573;
text-shadow: 0 0 5px #2ed573;
}
}
}
}
.black-screen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000000;
z-index: 99999;
cursor: pointer;
}
.landscape-playlist-btn {
writing-mode: vertical-rl;
text-transform: uppercase;
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 1px;
height: auto;
min-height: 75px;
width: 30px;
margin: 0;
--padding-start: 2px;
--padding-end: 2px;
color: var(--ion-color-secondary);
display: flex;
align-items: center;
justify-content: center;
transform: rotate(180deg);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@
<ion-icon name="reorder-two-outline"></ion-icon>
</div>
<div class="song-info">
<span class="canto-number">{{ song.id_canti }}</span>
<span class="canto-number">{{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }}</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>
+30 -5
View File
@@ -60,6 +60,15 @@ export class PlaylistPage {
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex);
}
getPlaylistSongSettings(): any {
const activeId = this.playlistService.activePlaylistId();
if (activeId) {
const pl = this.playlistService.playlists().find(p => p.id === activeId);
return pl ? pl.songSettings : undefined;
}
return undefined;
}
async savePlaylist() {
const alert = await this.alertCtrl.create({
header: 'Salva Playlist',
@@ -81,7 +90,7 @@ export class PlaylistPage {
handler: (data) => {
if (data.name) {
this.savedPlaylistName = data.name;
this.playlistService.savePlaylist(data.name, this.localSongs.map(s => s.id));
this.playlistService.savePlaylist(data.name, this.localSongs.map(s => s.id), this.getPlaylistSongSettings());
this.showToast('Playlist salvata!');
this.router.navigate(['/settings']);
return true;
@@ -117,8 +126,9 @@ export class PlaylistPage {
if (data.name) {
this.savedPlaylistName = data.name;
const ids = this.localSongs.map(s => s.id);
await this.playlistService.savePlaylist(data.name, ids);
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name);
const songSettings = this.getPlaylistSongSettings();
await this.playlistService.savePlaylist(data.name, ids, songSettings);
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name, songSettings);
this.showToast('Playlist salvata!');
return true;
}
@@ -135,7 +145,8 @@ export class PlaylistPage {
const ids = this.localSongs.map(s => s.id);
const name = this.savedPlaylistName || 'Playlist Condivisa';
this.qrCodeImage = await this.playlistService.generateQR(ids, name);
const songSettings = this.getPlaylistSongSettings();
this.qrCodeImage = await this.playlistService.generateQR(ids, name, songSettings);
}
downloadQR() {
@@ -157,7 +168,8 @@ export class PlaylistPage {
async shareQR() {
if (!this.qrCodeImage) return;
const name = this.savedPlaylistName || 'playlist';
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name);
const songSettings = this.getPlaylistSongSettings();
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name, songSettings);
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
@@ -180,6 +192,13 @@ export class PlaylistPage {
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
});
} else {
// Copy to clipboard AND download QR!
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
this.showToast('Link copiato negli appunti! QR scaricato.');
}
} catch(e) {}
this.downloadQR();
}
}
@@ -208,4 +227,10 @@ export class PlaylistPage {
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
return info && info.num_canto ? info.num_canto.toString() : null;
}
getMySongNumber(song: any): number {
if (!song || !song.id) return 0;
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
return index !== -1 ? index + 1 : 0;
}
}
@@ -7,7 +7,20 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding bg-gradient" [class.high-contrast-mode]="isHighContrast">
<ion-content class="ion-padding bg-gradient"
[class.high-contrast-mode]="isHighContrast"
[class.drag-over]="isDraggingOver"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)">
<div class="drag-overlay" *ngIf="isDraggingOver">
<div class="drag-message">
<ion-icon name="image-outline"></ion-icon>
<p>Rilascia l'immagine qui per estrarre il testo</p>
</div>
</div>
<div class="propose-container">
<!-- OCR Progress -->
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
@@ -18,6 +31,9 @@
</div>
</div>
<div class="editor-preview-split">
<!-- Left Column: Inputs & Editor -->
<div class="editor-column">
<ion-list lines="none" class="input-list">
<ion-item class="custom-input-item">
<ion-label position="stacked">Titolo del Canto</ion-label>
@@ -101,7 +117,8 @@
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea">
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</div>
@@ -121,6 +138,55 @@
</div>
</div>
<!-- Right Column: Active Preview Pane -->
<div class="preview-column">
<div class="preview-card" [class.hc]="isHighContrast">
<div class="preview-header">
<span class="preview-title">Anteprima Attiva</span>
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn">
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
</ion-button>
</div>
<div class="preview-body">
<div class="preview-song-header">
<h2 class="preview-song-title">{{ title || 'Titolo del Canto' }}</h2>
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
</div>
<div class="preview-lyrics-container">
<div *ngFor="let section of parsedSections"
class="preview-section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="preview-section-label preview-verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines" class="preview-lyric-line">
<!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="preview-chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="preview-chord">{{ seg.chord }}</span>
<span class="preview-seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
</div>
</div>
<div class="preview-empty" *ngIf="!content">
Il testo formattato apparirà qui mentre scrivi...
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden inputs -->
@@ -113,7 +113,8 @@ body.high-contrast :host ::ng-deep {
}
.propose-container {
max-width: 800px;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
@@ -419,3 +420,284 @@ body.high-contrast :host ::ng-deep {
}
}
}
/* DRAG AND DROP OVERLAY */
.drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
.drag-message {
text-align: center;
color: var(--ion-color-secondary);
background: rgba(255, 255, 255, 0.1);
border: 3px dashed var(--ion-color-secondary);
border-radius: 20px;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
ion-icon {
font-size: 64px;
}
p {
font-size: 20px;
font-weight: 700;
margin: 0;
}
}
}
body.high-contrast :host ::ng-deep {
.drag-overlay {
background: rgba(255, 255, 255, 0.9);
.drag-message {
color: #000000;
background: #ffffff;
border: 3px dashed #000000;
}
}
}
/* RESPONSIVE LAYOUT & ACTIVE PREVIEW PANE */
.editor-preview-split {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
align-items: flex-start;
@media (min-width: 992px) {
grid-template-columns: 1fr 1fr;
}
}
.preview-column {
@media (min-width: 992px) {
position: sticky;
top: 16px;
}
}
.preview-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
.preview-header {
background: rgba(255, 255, 255, 0.04);
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.preview-title {
font-size: 11px;
font-weight: 800;
color: var(--ion-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-toggle-btn {
--color: rgba(255, 255, 255, 0.7);
margin: 0;
font-size: 11px;
font-weight: 600;
ion-icon {
font-size: 14px;
}
}
}
.preview-body {
padding: 24px;
min-height: 400px;
background: rgba(0, 0, 0, 0.2);
.preview-song-header {
margin-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 14px;
.preview-song-title {
font-size: 1.6rem;
font-weight: 700;
color: #ffffff;
margin: 0 0 6px 0;
font-family: 'Outfit', sans-serif;
}
.preview-song-author {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
font-weight: 500;
}
}
.preview-lyrics-container {
font-size: 1.1rem;
font-family: 'Outfit', sans-serif;
overflow-y: auto;
max-height: 60vh;
padding-right: 8px;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.preview-section {
margin-bottom: 1.8rem;
position: relative;
&.chorus {
background: rgba(var(--ion-color-secondary-rgb), 0.03);
border-left: 3px solid var(--ion-color-secondary);
padding-left: 1rem;
margin-left: -1rem;
border-radius: 0 8px 8px 0;
}
.preview-section-label {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 700;
color: var(--ion-color-secondary);
margin-bottom: 0.4rem;
opacity: 0.7;
letter-spacing: 1px;
}
}
.preview-lyric-line {
margin-bottom: 0.8rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
min-height: 1.5em;
}
.preview-chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
margin-right: 0.25em;
&.contiguous-next {
margin-right: 0 !important;
}
.preview-chord {
font-size: 0.78em;
font-weight: 700;
color: var(--ion-color-secondary);
height: 1.25em;
margin-bottom: -0.2em;
}
.preview-seg-text {
white-space: pre;
&::after {
content: '\200b';
}
}
}
.preview-empty {
color: rgba(255, 255, 255, 0.3);
text-align: center;
padding-top: 80px;
font-style: italic;
font-size: 0.95rem;
}
}
}
/* High Contrast mode overrides */
&.hc {
background: #ffffff;
border: 2px solid #000000;
box-shadow: none;
.preview-header {
background: #f0f0f0;
border-bottom: 2px solid #000000;
.preview-title {
color: #000000;
}
.preview-toggle-btn {
--color: #000000;
font-weight: 700;
}
}
.preview-body {
background: #ffffff;
.preview-song-header {
border-bottom: 2px solid #000000;
.preview-song-title {
color: #000000;
}
.preview-song-author {
color: #333333;
}
}
.preview-lyrics-container {
.preview-section {
&.chorus {
background: #f5f5f5;
border-left: 3px solid #000000;
}
.preview-section-label {
color: #000000;
}
}
.preview-lyric-line {
color: #000000;
}
.preview-chord-segment {
.preview-chord {
color: #000000;
text-decoration: underline;
font-weight: 800;
}
}
.preview-empty {
color: #666666;
}
}
}
}
}
@@ -1,17 +1,127 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProposeCantoPage } from './propose-canto.page';
import { ToastController, PopoverController, NavController, AngularDelegate } from '@ionic/angular';
import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, Router } from '@angular/router';
import { LyricsParserService } from '../../services/lyrics-parser.service';
import { of } from 'rxjs';
describe('ProposeCantoPage', () => {
let component: ProposeCantoPage;
let fixture: ComponentFixture<ProposeCantoPage>;
beforeEach(() => {
const cantiServiceMock = {
indiceLiturgico: () => [],
indiceTematico: () => [],
canti: () => []
};
const myCantiServiceMock = {
myCanti: () => []
};
const playlistServiceMock = {
remoteCustomSongs: () => [],
activePlaylistId: () => null
};
const themeServiceMock = {
highContrast: () => false
};
const activatedRouteMock = {
queryParams: of({})
};
const routerMock = {
navigate: jasmine.createSpy('navigate')
};
const lyricsParserMock = {
parseAccordi: () => []
};
const navCtrlMock = {};
const toastControllerMock = {};
const popoverControllerMock = {};
const angularDelegateMock = {};
TestBed.configureTestingModule({
providers: [
{ provide: CantiService, useValue: cantiServiceMock },
{ provide: MyCantiService, useValue: myCantiServiceMock },
{ provide: PlaylistService, useValue: playlistServiceMock },
{ provide: ThemeService, useValue: themeServiceMock },
{ provide: ActivatedRoute, useValue: activatedRouteMock },
{ provide: Router, useValue: routerMock },
{ provide: LyricsParserService, useValue: lyricsParserMock },
{ provide: NavController, useValue: navCtrlMock },
{ provide: ToastController, useValue: toastControllerMock },
{ provide: PopoverController, useValue: popoverControllerMock },
{ provide: AngularDelegate, useValue: angularDelegateMock }
]
});
fixture = TestBed.createComponent(ProposeCantoPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('convertEnglishChordToItalian', () => {
it('should convert simple English chords to Italian', () => {
expect(component.convertEnglishChordToItalian('C')).toBe('DO');
expect(component.convertEnglishChordToItalian('D')).toBe('RE');
expect(component.convertEnglishChordToItalian('E')).toBe('MI');
expect(component.convertEnglishChordToItalian('F')).toBe('FA');
expect(component.convertEnglishChordToItalian('G')).toBe('SOL');
expect(component.convertEnglishChordToItalian('A')).toBe('LA');
expect(component.convertEnglishChordToItalian('B')).toBe('SI');
});
it('should convert English chords with accidentals and modifiers', () => {
expect(component.convertEnglishChordToItalian('C#m7')).toBe('DO#m7');
expect(component.convertEnglishChordToItalian('Bb')).toBe('SIb');
expect(component.convertEnglishChordToItalian('F#m')).toBe('FA#m');
expect(component.convertEnglishChordToItalian('Faug')).toBe('FAaug');
expect(component.convertEnglishChordToItalian('Fadd9')).toBe('FAadd9');
});
it('should keep Italian chords unchanged', () => {
expect(component.convertEnglishChordToItalian('DO')).toBe('DO');
expect(component.convertEnglishChordToItalian('RE#m7')).toBe('RE#m7');
expect(component.convertEnglishChordToItalian('FA#')).toBe('FA#');
expect(component.convertEnglishChordToItalian('SIb')).toBe('SIb');
});
it('should handle slash chords correctly', () => {
expect(component.convertEnglishChordToItalian('C/E')).toBe('DO/MI');
expect(component.convertEnglishChordToItalian('D/F#')).toBe('RE/FA#');
expect(component.convertEnglishChordToItalian('F#m7/A#')).toBe('FA#m7/LA#');
});
});
describe('sanitizeOcrChord', () => {
it('should convert H to # when following a chord root', () => {
expect(component.sanitizeOcrChord('CH')).toBe('C#');
expect(component.sanitizeOcrChord('DHm7')).toBe('D#m7');
expect(component.sanitizeOcrChord('FH#')).toBe('F#');
expect(component.sanitizeOcrChord('FAH')).toBe('FA#');
expect(component.sanitizeOcrChord('SOLH7')).toBe('SOL#7');
expect(component.sanitizeOcrChord('FH#H')).toBe('F#');
expect(component.sanitizeOcrChord('FH#H/AH#')).toBe('F#/A#');
expect(component.sanitizeOcrChord('C#H-')).toBe('C#-');
expect(component.sanitizeOcrChord('G#H#')).toBe('G#');
});
it('should convert 0 to O for DO and SOL roots', () => {
expect(component.sanitizeOcrChord('D0')).toBe('DO');
expect(component.sanitizeOcrChord('D0#')).toBe('DO#');
expect(component.sanitizeOcrChord('S0L')).toBe('SOL');
});
it('should handle slash chords with sanitization', () => {
expect(component.sanitizeOcrChord('CH/EH')).toBe('C#/E#');
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
});
});
});
+480 -23
View File
@@ -5,14 +5,17 @@ import { IonicModule, ToastController, IonTextarea, PopoverController, NavContro
import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
@Component({
selector: 'app-propose-canto',
templateUrl: './propose-canto.page.html',
styleUrls: ['./propose-canto.page.scss'],
standalone: true,
imports: [CommonModule, FormsModule, IonicModule]
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
})
export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
@@ -20,14 +23,29 @@ export class ProposeCantoPage implements OnInit {
public cantiService = inject(CantiService);
private myCantiService = inject(MyCantiService);
private playlistService = inject(PlaylistService);
private navCtrl = inject(NavController);
public themeService = inject(ThemeService);
private route = inject(ActivatedRoute);
private router = inject(Router);
public lyricsParser = inject(LyricsParserService);
showChordsPreview: boolean = true;
get parsedSections(): ParsedSection[] {
return this.lyricsParser.parseAccordi(this.content);
}
toggleChordsPreview() {
this.showChordsPreview = !this.showChordsPreview;
}
title: string = '';
author: string = '';
youtubeLink: string = '';
selectedLiturgico: number[] = [];
selectedTematico: number[] = [];
editId: string | null = null;
private _content: string = '';
get content(): string { return this._content; }
@@ -41,6 +59,7 @@ export class ProposeCantoPage implements OnInit {
undoStack: string[] = [];
isProcessingOCR: boolean = false;
ocrProgress: number = 0;
isDraggingOver: boolean = false;
get isHighContrast(): boolean { return this.themeService.highContrast(); }
groupedChords = [
@@ -104,6 +123,33 @@ export class ProposeCantoPage implements OnInit {
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
ngOnInit() {
this.route.queryParams.subscribe(params => {
const editId = params['editId'];
if (editId) {
this.editId = editId;
// Find the song in standard canti, personal canti, or remote custom canti list
const song = [
...this.cantiService.canti(),
...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs(),
...this.playlistService.remoteShareCanti()
].find(c => c.id === editId);
if (song) {
this.title = song.titolo;
this.author = song.autore || '';
this.youtubeLink = song.link_youtube || '';
this.content = song.accordi || song.testo || '';
// Pre-populate liturgico and tematico lists
const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
const temIds = this.cantiService.indiceTematico().map(m => m.id);
this.selectedLiturgico = song.id_momenti?.filter((id: number) => litIds.includes(id)) || [];
this.selectedTematico = song.id_momenti?.filter((id: number) => temIds.includes(id)) || [];
}
}
});
}
async insertText(tag: string) {
@@ -143,13 +189,70 @@ export class ProposeCantoPage implements OnInit {
this.cameraInput.nativeElement.click();
}
onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Mostra l'overlay solo se si sta trascinando un file
if (event.dataTransfer?.types.includes('Files')) {
this.isDraggingOver = true;
}
}
onDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
}
async onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0];
if (file.type.indexOf('image') !== -1) {
await this.processImageFile(file);
} else {
const toast = await this.toastController.create({
message: 'Per favore, trascina un file immagine valido.',
duration: 3000,
color: 'warning'
});
toast.present();
}
}
}
async onFileSelected(event: any, isCamera: boolean) {
const file = event.target.files[0];
if (!file) {
console.log('[OCR-Capture] Nessun file selezionato.');
return;
}
await this.processImageFile(file);
event.target.value = '';
}
async onPaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
event.preventDefault(); // Prevent pasting the image representation as text
const blob = items[i].getAsFile();
if (blob) {
const file = new File([blob], 'pasted-image.png', { type: blob.type });
await this.processImageFile(file);
}
break;
}
}
}
async processImageFile(file: File) {
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
this.isProcessingOCR = true;
@@ -187,7 +290,6 @@ export class ProposeCantoPage implements OnInit {
} finally {
this.isProcessingOCR = false;
this.ocrProgress = 0;
event.target.value = '';
}
}
@@ -279,12 +381,95 @@ export class ProposeCantoPage implements OnInit {
return this.parseSongSpatially(words);
}
sanitizeOcrChord(text: string): string {
if (!text) return text;
if (text.includes('/')) {
return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/');
}
let cleaned = text;
// Replace D0/d0 with DO/do
cleaned = cleaned.replace(/^D0/gi, 'DO');
// Replace S0L/s0l with SOL/sol
cleaned = cleaned.replace(/^S0L/gi, 'SOL');
// Clean H/sharp mismatches:
cleaned = cleaned.replace(/H#/gi, '#');
cleaned = cleaned.replace(/#H/gi, '#');
cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#');
// Clean duplicate sharps (e.g. ## -> #)
cleaned = cleaned.replace(/##+/g, '#');
return cleaned;
}
convertEnglishChordToItalian(chord: string): string {
if (!chord) return chord;
if (chord.includes('/')) {
return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim())).join('/');
}
const upper = chord.toUpperCase();
if (upper.startsWith('DO')) {
return chord;
}
if (upper.startsWith('FA')) {
if (upper.startsWith('FAUG') || upper.startsWith('FADD') || upper.startsWith('FALT')) {
return 'FA' + chord.slice(1);
}
return chord;
}
if (upper.startsWith('C')) {
return 'DO' + chord.slice(1);
}
if (upper.startsWith('D')) {
return 'RE' + chord.slice(1);
}
if (upper.startsWith('E')) {
return 'MI' + chord.slice(1);
}
if (upper.startsWith('F')) {
return 'FA' + chord.slice(1);
}
if (upper.startsWith('G')) {
return 'SOL' + chord.slice(1);
}
if (upper.startsWith('A')) {
return 'LA' + chord.slice(1);
}
if (upper.startsWith('B')) {
return 'SI' + chord.slice(1);
}
return chord;
}
isLabelLine(text: string): boolean {
return /^(Intro|Strofa|Rit|Special|Coro|Bridge|RIT|CHORUS|VERSE)/i.test(text.trim());
}
parseSongSpatially(words: any[]): string {
if (!words || words.length === 0) {
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
return '';
}
// Preprocess words to split run-together chords like BA
const preprocessedWords: any[] = [];
words.forEach(w => {
if (!w.text) return;
const match = w.text.match(/^([ABCDEFG])([ABCDEFG])$/i);
if (match && !(match[1].toUpperCase() === 'F' && match[2].toUpperCase() === 'A')) {
const charWidth = (w.bbox.x1 - w.bbox.x0) / 2;
preprocessedWords.push({
text: match[1],
bbox: { ...w.bbox, x1: w.bbox.x0 + charWidth }
});
preprocessedWords.push({
text: match[2],
bbox: { ...w.bbox, x0: w.bbox.x0 + charWidth }
});
} else {
preprocessedWords.push(w);
}
});
words = preprocessedWords;
console.log(`[OCR-Capture] Parole totali ricevute dall'OCR: ${words.length}`);
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
console.log(`[OCR-Capture] Parole valide dopo filtraggio: ${validWords.length}`);
@@ -293,7 +478,7 @@ export class ProposeCantoPage implements OnInit {
// Calculate average word height to set vertical tolerance
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 0.6;
const verticalTolerance = avgHeight * 0.85;
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
// 2. Group words into horizontal lines
@@ -324,16 +509,49 @@ export class ProposeCantoPage implements OnInit {
});
// 3. Classify lines as Chords vs. Text
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?$/i;
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
const isChordWord = (text: string): boolean => {
const clean = text.replace(/[\[\]\(\)\.\,\-\+]/g, '').trim().toUpperCase();
if (!text || text.length === 0) return false;
const lower = text.toLowerCase();
// Skip common valid words written purely in lowercase
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
return false;
}
let clean = text.toUpperCase().replace(/\s+/g, '');
clean = clean.replace(/\((.*?)\)/g, '/$1');
clean = clean.replace(/[\.\,]$/g, '');
clean = this.sanitizeOcrChord(clean);
if (clean.includes('_')) {
const parts = clean.split('_').filter(p => p.length > 0);
if (parts.length === 0) return false;
return parts.every(p => chordRegex.test(p));
}
return chordRegex.test(clean);
};
const classifiedLines = lines.map(line => {
const chordCount = line.filter(w => isChordWord(w.text)).length;
let chordCount = 0;
let hasLongNonChord = false;
line.forEach(w => {
if (isChordWord(w.text)) {
chordCount++;
} else {
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
if (clean.length > 5) {
hasLongNonChord = true;
}
}
});
const ratio = line.length > 0 ? chordCount / line.length : 0;
const isChords = ratio >= 0.4 && line.length <= 10;
let isChords = false;
if (ratio >= 0.4 && line.length <= 10) {
if (!hasLongNonChord || ratio >= 0.75) {
isChords = true;
}
}
return {
words: line,
@@ -353,7 +571,8 @@ export class ProposeCantoPage implements OnInit {
if (current.isChords) {
const next = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
if (next && !next.isChords) {
const nextText = next ? next.words.map(w => w.text).join(' ') : '';
if (next && !next.isChords && !this.isLabelLine(nextText)) {
// Merge spatially!
const merged = this.mergeChordsAndLyrics(current.words, next.words);
const lineText = next.words.map(w => w.text).join(' ');
@@ -371,9 +590,38 @@ export class ProposeCantoPage implements OnInit {
i++; // Skip next line because we consumed it!
} else {
// Chord line but no text below it: just wrap and print
const wrapped = current.words.map(w => `[${w.text.replace(/[\(\)\[\]]/g, '').toUpperCase()}]`).join(' ');
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
const expandedChords: string[] = [];
current.words.forEach(w => {
const parts = w.text.split(/(_)/);
const processedParts = parts.map((part: string) => {
if (part === '_') return ' _ ';
if (!part.trim()) return part;
let cleanText = part.toUpperCase().replace(/\s+/g, '');
cleanText = cleanText.replace(/\((.*?)\)/g, '/$1');
cleanText = cleanText.replace(/[\.\,]$/g, '');
cleanText = this.sanitizeOcrChord(cleanText);
if (chordRegex.test(cleanText)) {
return `[${this.convertEnglishChordToItalian(cleanText)}]`;
} else {
const matches = [...cleanText.matchAll(multiChordRegex)];
const fullMatchStr = matches.map(m => m[0]).join('');
if (matches.length > 0 && fullMatchStr === cleanText) {
return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase())}]`).join(' ');
} else {
return part;
}
}
});
expandedChords.push(processedParts.join(''));
});
const wrapped = expandedChords.join(' ');
if (wrapped) {
processedLines.push(wrapped);
}
}
} else {
const lineText = current.words.map(w => w.text).join(' ');
const isChorus = chorusStartRegex.test(lineText);
@@ -386,7 +634,7 @@ export class ProposeCantoPage implements OnInit {
inVerse = true;
}
processedLines.push(lineText);
processedLines.push(this.wrapChords(lineText, chordRegex));
}
// If we see a large vertical gap, close open blocks
@@ -408,17 +656,136 @@ export class ProposeCantoPage implements OnInit {
}
mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
let result = '';
// Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
let mergedChordWords: any[] = [];
for (let i = 0; i < chordWords.length; i++) {
let cw = chordWords[i];
if (cw.text.startsWith('(') && mergedChordWords.length > 0) {
let prev = mergedChordWords[mergedChordWords.length - 1];
prev.text += cw.text;
prev.bbox.x1 = Math.max(prev.bbox.x1, cw.bbox.x1);
if (!prev.text.includes(')')) {
let j = i + 1;
while (j < chordWords.length) {
prev.text += chordWords[j].text;
prev.bbox.x1 = Math.max(prev.bbox.x1, chordWords[j].bbox.x1);
if (chordWords[j].text.includes(')')) {
i = j;
break;
}
j++;
}
}
} else {
let text = cw.text;
let bbox = { ...cw.bbox };
if (text.includes('(') && !text.includes(')')) {
let j = i + 1;
while (j < chordWords.length) {
text += chordWords[j].text;
bbox.x1 = Math.max(bbox.x1, chordWords[j].bbox.x1);
if (chordWords[j].text.includes(')')) {
i = j;
break;
}
j++;
}
}
mergedChordWords.push({ text, bbox });
}
}
// Sanitize common OCR errors in chords (e.g., 'Re(f fa#)' -> 'Re(fa#)')
mergedChordWords.forEach(cw => {
cw.text = cw.text.replace(/f\s*fa#/gi, 'fa#');
cw.text = cw.text.replace(/ff/gi, 'f');
cw.text = cw.text.replace(/m\s*mi/gi, 'mi');
cw.text = cw.text.replace(/mm/gi, 'm');
});
if (!textWords || textWords.length === 0) {
return mergedChordWords.map(c => {
const parts = c.text.split(/(_)/);
return parts.map((part: string) => {
if (part === '_') return ' _ ';
if (!part.trim()) return part;
let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
return `[${this.convertEnglishChordToItalian(clean)}]`;
}).join('');
}).join(' ');
}
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
const expandedChordWords: any[] = [];
mergedChordWords.forEach(chord => {
const parts = chord.text.split(/(_)/);
let currentX = chord.bbox.x0;
const totalLen = chord.text.length || 1;
const widthPerChar = (chord.bbox.x1 - chord.bbox.x0) / totalLen;
parts.forEach((part: string) => {
const partLen = part.length;
const partWidth = partLen * widthPerChar;
const partX0 = currentX;
const partX1 = currentX + partWidth;
currentX = partX1;
if (part === '_' || !part.trim()) {
return;
}
let originalText = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
originalText = this.sanitizeOcrChord(originalText);
const matches = [...originalText.matchAll(multiChordRegex)];
if (matches.length === 0) {
expandedChordWords.push({
text: originalText,
bbox: { ...chord.bbox, x0: partX0, x1: partX1 }
});
} else {
const fullMatchStr = matches.map(m => m[0]).join('');
if (fullMatchStr === originalText) {
const matchCharWidth = (partX1 - partX0) / Math.max(1, originalText.length);
matches.forEach(match => {
const matchIndex = match.index!;
const matchLength = match[0].length;
const newX0 = partX0 + matchIndex * matchCharWidth;
const newX1 = partX0 + (matchIndex + matchLength) * matchCharWidth;
expandedChordWords.push({
text: match[0],
bbox: { ...chord.bbox, x0: newX0, x1: newX1 }
});
});
} else {
expandedChordWords.push({
text: originalText,
bbox: { ...chord.bbox, x0: partX0, x1: partX1 }
});
}
}
});
});
const chordAssignments = new Map<any, any[]>();
chordWords.forEach(chord => {
expandedChordWords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let closestWord: any = null;
let minDistance = Infinity;
textWords.forEach(textWord => {
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
const dist = Math.abs(chordX - wordXCenter);
let dist = 0;
if (chordX < textWord.bbox.x0) {
dist = textWord.bbox.x0 - chordX;
} else if (chordX > textWord.bbox.x1) {
dist = chordX - textWord.bbox.x1;
}
if (dist < minDistance) {
minDistance = dist;
closestWord = textWord;
@@ -433,22 +800,50 @@ export class ProposeCantoPage implements OnInit {
}
});
let result = '';
textWords.forEach((textWord, index) => {
const assignedChords = chordAssignments.get(textWord) || [];
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
const wordText = textWord.text;
let charWidth = (textWord.bbox.x1 - textWord.bbox.x0) / Math.max(1, wordText.length);
if (charWidth <= 0) charWidth = 6; // safe fallback
let lastCharIndex = 0;
let wordResult = '';
assignedChords.forEach(chord => {
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
result += `[${cleanChord}]`;
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let charIndex = Math.round((chordX - textWord.bbox.x0) / charWidth);
if (charIndex < 0) charIndex = 0;
if (charIndex > wordText.length) charIndex = wordText.length;
let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
cleanChord = this.sanitizeOcrChord(cleanChord);
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord)}]`;
lastCharIndex = charIndex;
});
result += textWord.text;
wordResult += wordText.substring(lastCharIndex);
result += wordResult;
if (index < textWords.length - 1) {
result += ' ';
}
});
return result;
let finalResult = result.replace(/\]\[/g, '] [');
finalResult = finalResult.replace(/\]_\[/g, '] _ [');
finalResult = finalResult.replace(/\]_/g, '] _ ');
finalResult = finalResult.replace(/_\[/g, ' _ [');
// Normalize any duplicate spaces around underscores:
finalResult = finalResult.replace(/\s*_\s*/g, ' _ ');
console.log(`[OCR-Debug] Linea generata: ${finalResult}`);
return finalResult;
}
@@ -490,11 +885,22 @@ export class ProposeCantoPage implements OnInit {
}
private wrapChords(line: string, regex: RegExp): string {
const chordsInLine = line.match(regex);
if (chordsInLine && chordsInLine.length > 0) {
return line.replace(regex, (match) => `[${match.toUpperCase()}]`);
const globalRegex = new RegExp(regex.source.replace(/^\^/, '').replace(/\$$/, ''), 'gi');
const parts = line.split(/(_)/);
return parts.map((part: string) => {
if (part === '_') return ' _ ';
return part.replace(globalRegex, (match) => {
const lower = match.toLowerCase();
if (match === 'a' || match === 'e' || match === 'o' || match === 'i') {
return match;
}
return line;
if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower) && match === lower) {
return match;
}
const sanitized = this.sanitizeOcrChord(match.toUpperCase());
return `[${this.convertEnglishChordToItalian(sanitized)}]`;
});
}).join('');
}
@@ -505,7 +911,45 @@ export class ProposeCantoPage implements OnInit {
// Combine lit and tematico for id_momenti
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
await this.myCantiService.saveCanto({
const activePlaylistId = this.playlistService.activePlaylistId();
const isRemotePlaylist = activePlaylistId && activePlaylistId.startsWith('remote_');
if (isRemotePlaylist) {
// 1. Clone the song (generate a brand new my_... ID)
const savedCanto = await this.myCantiService.saveCanto({
titolo: this.title,
autore: this.author,
link_youtube: this.youtubeLink,
testo: this.content,
accordi: this.content,
id_momenti: id_momenti
});
// 2. Clone/convert remote playlist to local personal playlist
const remotePl = this.playlistService.remotePlaylist();
if (remotePl) {
const originalIds = remotePl.ids || [];
const updatedIds = originalIds.map((id: string) => id === this.editId ? savedCanto.id : id);
const songSettings = { ...(remotePl.songSettings || {}) };
if (this.editId && songSettings[this.editId]) {
songSettings[savedCanto.id] = { ...songSettings[this.editId] };
delete songSettings[this.editId];
}
const localName = remotePl.name.replace('[Remote] ', '');
// Force save as a new local playlist
this.playlistService.activePlaylistId.set(null);
await this.playlistService.savePlaylist(localName, updatedIds, songSettings);
}
// Navigate to the player with the new cloned song ID immediately
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else {
// Standard path
const savedCanto = await this.myCantiService.saveCanto({
id: this.editId || undefined,
titolo: this.title,
autore: this.author,
link_youtube: this.youtubeLink,
@@ -514,6 +958,19 @@ export class ProposeCantoPage implements OnInit {
id_momenti: id_momenti
});
if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) {
await this.playlistService.replaceSongIdInPlaylists(this.editId, savedCanto.id);
}
if (this.editId && this.editId.startsWith('remote_share_')) {
await this.playlistService.deleteRemoteShareCanto(this.editId);
}
if (this.editId && savedCanto && savedCanto.id && this.editId !== savedCanto.id) {
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else {
this.navCtrl.back();
}
}
}
}
+155 -56
View File
@@ -35,11 +35,11 @@
<ion-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
</ion-item>
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02);">
<p class="outfit-font" style="font-size: 0.85rem; color: rgba(255,255,255,0.8); margin: 0 0 12px 0; line-height: 1.4;">
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid var(--ion-border-color, rgba(255,255,255,0.08)); background: rgba(var(--ion-text-color-rgb, 255,255,255), 0.02);">
<p class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0 0 12px 0; line-height: 1.4; opacity: 0.9;">
Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
</p>
<ol class="outfit-font" style="font-size: 0.85rem; color: rgba(255,255,255,0.8); margin: 0; padding-left: 20px; line-height: 1.6;">
<ol class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0; padding-left: 20px; line-height: 1.6; opacity: 0.9;">
<li style="margin-bottom: 8px;">
Tocca il pulsante di <strong>Condivisione</strong> <ion-icon name="share-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon> nella barra di navigazione inferiore di Safari.
</li>
@@ -62,14 +62,7 @@
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="expand-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Nascondi Barre (Player)</h2>
<p class="settings-item-subtitle">Modalità immersiva nel dettaglio canto</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.fullscreenMode()" (ionChange)="settingsService.toggleFullscreenMode()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
@@ -86,14 +79,7 @@
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.autoAdvance()" (ionChange)="settingsService.toggleAutoAdvance()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="sunny-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Schermo Sempre Acceso</h2>
<p class="settings-item-subtitle">Evita che lo schermo si spenga</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.keepScreenOn()" (ionChange)="settingsService.toggleKeepScreenOn()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="create-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
@@ -127,12 +113,28 @@
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="mic-outline" slot="start" color="secondary"></ion-icon>
<ion-icon name="eye-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Autoscroll acustico</h2>
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p>
<h2 class="settings-item-title">Autoscroll visuale</h2>
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle>
<ion-toggle slot="end" [checked]="settingsService.enableVisualAutoscroll()" (ionChange)="settingsService.toggleVisualAutoscroll()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Avanzamento manuale a pagine</h2>
<p class="settings-item-subtitle">I tasti Avanti/Indietro (Karaoke) voltano la pagina intera anziché riga per riga</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.karaokePageScrollMode()" (ionChange)="settingsService.toggleKaraokePageScrollMode()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
<ion-icon name="phone-landscape-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Vista orizzontale per proiezione</h2>
<p class="settings-item-subtitle">Adatta il layout in landscape per la proiezione (testo più grande, controlli dedicati)</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.landscapeProjectionEnabled()" (ionChange)="settingsService.toggleLandscapeProjectionEnabled()" color="secondary"></ion-toggle>
</ion-item>
</div>
@@ -144,8 +146,109 @@
<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-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="onComunitaToggleChange($event)" color="secondary"></ion-toggle>
</ion-item>
<!-- Community Details/Form when enabled -->
<div class="ion-padding-horizontal ion-padding-bottom" *ngIf="settingsService.comunitaEnabled()" style="border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px;">
<div *ngIf="comunitaService.comunitaCode()" class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between;">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Comunità Attiva</div>
<div style="font-size: 0.9rem; font-weight: 500; color: var(--ion-text-color); overflow: hidden; text-overflow: ellipsis;">{{ comunitaService.comunitaNome() }}</div>
<div style="font-size: 0.75rem; opacity: 0.7; color: var(--ion-text-color); margin-top: 2px;">Codice: {{ comunitaService.comunitaCode() }}</div>
</div>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<ion-button fill="clear" size="small" color="secondary" (click)="editComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
<ion-icon name="create-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="danger" (click)="removeComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
<ion-icon name="trash-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
</ion-button>
</div>
</div>
<div *ngIf="!comunitaService.comunitaCode()" style="display: flex; flex-direction: column; gap: 12px;">
<p class="settings-item-subtitle outfit-font" style="margin: 0; font-size: 0.8rem; opacity: 0.85;">
Inserisci il codice parrocchiale/comunità per scaricare i canti e le scalette dedicate:
</p>
<div style="display: flex; gap: 10px; align-items: center;">
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
<input type="text"
#comunitaCodeInput
placeholder="Es: 123456"
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
class="outfit-font"
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
</div>
<ion-button fill="solid" color="secondary" size="small" (click)="saveComunitaCode(comunitaCodeInput.value)" class="outfit-font" style="--border-radius: 10px; font-weight: bold; text-transform: none; margin: 0; height: 36px;">
Attiva
</ion-button>
</div>
</div>
</div>
</div>
<!-- Identità Utente e Ripristino -->
<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" style="color: var(--ion-color-secondary); font-weight: bold; font-size: 1.05rem;">
Identità Utente e Ripristino
</h2>
</div>
<div class="ion-padding-horizontal ion-padding-bottom">
<p class="settings-item-subtitle outfit-font" style="margin: 0 0 16px 0; font-size: 0.85rem; line-height: 1.45; opacity: 0.85; color: var(--ion-text-color);">
Questo codice univoco identifica in modo anonimo il tuo dispositivo e ti consente di gestire comunità e canti. Salva il codice o il QR code per ripristinare il tuo profilo su un nuovo dispositivo.
</p>
<!-- Nome Associato all'Identità -->
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; margin-bottom: 12px;">
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 4px;">Nome</div>
<input type="text"
[value]="settingsService.userName()"
(input)="onNameChange($event)"
placeholder="Inserisci il tuo nome..."
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none; padding: 4px 0;"
class="outfit-font">
</div>
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Codice ID</div>
<div style="font-family: monospace; font-size: 0.78rem; opacity: 0.9; color: var(--ion-text-color);" class="select-all">{{ settingsService.userUuid() }}</div>
</div>
<ion-button fill="clear" size="small" (click)="showBackupQrCode()" color="secondary" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
<ion-icon name="qr-code-outline" slot="icon-only" style="font-size: 1.35rem;"></ion-icon>
</ion-button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<ion-button expand="block" fill="outline" color="secondary" (click)="scanRestoreQrCode()" class="outfit-font" style="--border-radius: 10px; font-size: 0.8rem; font-weight: bold; text-transform: none; margin: 0; height: 38px;">
<ion-icon name="scan-outline" slot="start"></ion-icon>
Scansiona QR
</ion-button>
<ion-button expand="block" fill="outline" color="secondary" (click)="manualRestore()" class="outfit-font" style="--border-radius: 10px; font-size: 0.8rem; font-weight: bold; text-transform: none; margin: 0; height: 38px;">
<ion-icon name="keypad-outline" slot="start"></ion-icon>
Inserisci Codice
</ion-button>
</div>
<ion-button *ngIf="settingsService.userUuid() !== settingsService.originalUserUuid()"
expand="block" fill="outline" color="warning" (click)="restoreOriginalIdentity()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
<ion-icon name="refresh-outline" slot="start"></ion-icon>
Ripristina ID Originario
</ion-button>
<ion-button expand="block" fill="solid" color="secondary" (click)="syncLocalDataToServer()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
<ion-icon name="cloud-upload-outline" slot="start"></ion-icon>
Salva Backup sul Server
</ion-button>
<ion-button expand="block" fill="outline" color="secondary" (click)="restoreBackupFromServer()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
<ion-icon name="cloud-download-outline" slot="start"></ion-icon>
Ripristina Backup dal Server
</ion-button>
</div>
</div>
<!-- Invio Dati Statistici -->
@@ -185,38 +288,34 @@
</div>
</div>
<!-- Chord Notation Preference Section -->
<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">
Notazione Accordi Preferita
</h2>
</div>
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
<div class="filter-btn glass"
[class.active-btn]="settingsService.chordNotationPreference() === 'diesis'"
(click)="settingsService.setChordNotationPreference('diesis')">
<span>Diesis (#)</span>
</div>
<div class="filter-btn glass"
[class.active-btn]="settingsService.chordNotationPreference() === 'bemolle'"
(click)="settingsService.setChordNotationPreference('bemolle')">
<span>Bemolle (b)</span>
</div>
</div>
</div>
</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>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Proponi i miei canti ({{ myCantiService.myCanti().length }})</h2>
<p class="settings-item-subtitle">Invia a {{ contactEmail }}</p>
</ion-label>
</ion-item>
</div>
<div class="settings-group glass ion-margin-top">
<ion-item class="transparent-item" lines="none" (click)="fullRefresh()" detail="true" button>
<ion-icon name="cloud-download-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Allinea con Server</h2>
<p class="settings-item-subtitle">Aggiorna canti e versione app</p>
</ion-label>
<ion-spinner slot="end" name="crescent" color="secondary" *ngIf="cantiService.loading()"></ion-spinner>
</ion-item>
<div class="sync-info ion-padding-bottom">
<p class="outfit-font settings-item-subtitle">
Versione: <strong>v{{ version }}</strong> &bull;
Canti: <strong>{{ cantiService.canti().length }}</strong>
</p>
</div>
<div class="sync-progress" *ngIf="cantiService.loading()">
<div class="progress-bar" [style.width.%]="cantiService.progress()"></div>
</div>
</div>
<!-- Legenda Pulsanti Canto -->
<div class="settings-group glass ion-margin-top">
@@ -234,12 +333,12 @@
<div class="legend-list">
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="mic" color="danger"></ion-icon>
<ion-icon name="videocam" color="success"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4>
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
<p class="outfit-font">
Attiva lo scorrimento vocale intelligente. L'app ascolta il canto o lo strumento e fa scorrere testo e accordi a tempo di musica, senza bisogno di toccare lo schermo. Uno slider verticale permette di regolare la sensibilità.
Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo.
</p>
</div>
</div>
@@ -280,7 +379,7 @@
<div class="legend-text">
<h4 class="outfit-font">Riavvia Canto</h4>
<p class="outfit-font">
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento vocale dello scroll acustico.
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento dello scroll visuale.
</p>
</div>
</div>
+425 -37
View File
@@ -3,16 +3,20 @@ import { ThemeService } from '../../services/theme.service';
import { SettingsService } from '../../services/settings.service';
import { CantiService } from '../../services/canti.service';
import { ConnectivityService } from '../../services/connectivity.service';
import { SwUpdate } from '@angular/service-worker';
import { ToastController, ModalController, AlertController } from '@ionic/angular';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { ToastController, ModalController, AlertController, LoadingController } from '@ionic/angular';
import { VERSION } from '../../version';
import { PlaylistService } from '../../services/playlist.service';
import { Router } from '@angular/router';
import { filter, first } from 'rxjs/operators';
import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service';
import { ComunitaService } from '../../services/comunita.service';
import { environment } from '../../../environments/environment';
import { showFullscreenUpdateOverlay } from '../../app.component';
import { QrScannerComponent } from '../../components/qr-scanner/qr-scanner.component';
import { IdentityQrModalComponent } from '../../components/identity-qr-modal/identity-qr-modal.component';
@Component({
selector: 'app-settings',
@@ -33,6 +37,7 @@ export class SettingsPage {
private toastCtrl = inject(ToastController);
private modalCtrl = inject(ModalController);
private alertCtrl = inject(AlertController);
private loadingCtrl = inject(LoadingController);
private router = inject(Router);
public version = VERSION;
@@ -50,62 +55,445 @@ export class SettingsPage {
this.showIosInstructions = !this.showIosInstructions;
}
async showBackupQrCode() {
const modal = await this.modalCtrl.create({
component: IdentityQrModalComponent,
componentProps: {
userUuid: this.settingsService.userUuid()
}
});
return await modal.present();
}
async scanRestoreQrCode() {
const modal = await this.modalCtrl.create({
component: QrScannerComponent
});
await modal.present();
const { data } = await modal.onWillDismiss();
if (data) {
this.confirmRestore(data);
}
}
async manualRestore() {
const alert = await this.alertCtrl.create({
header: 'Ripristina con Codice',
message: 'Digita o incolla il tuo codice identificativo univoco.',
inputs: [
{
name: 'code',
type: 'text',
placeholder: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx',
value: ''
}
],
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Conferma',
handler: (data) => {
if (data.code && data.code.trim()) {
this.confirmRestore(data.code.trim());
}
}
}
]
});
await alert.present();
}
private async confirmRestore(code: string) {
// Basic validation for UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(code)) {
const errorAlert = await this.alertCtrl.create({
header: 'Codice Non Valido',
message: 'Il codice inserito non sembra essere un identificativo univoco valido. Riprova.',
buttons: ['OK']
});
await errorAlert.present();
return;
}
const alert = await this.alertCtrl.create({
header: 'Ripristina Identità',
message: 'Sei sicuro di voler ripristinare questa identità? L\'ID attuale del dispositivo verrà sovrascritto permanentemente ed eventuali canti e playlist remoti associati al nuovo ID verranno scaricati.',
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Ripristina',
role: 'destructive',
handler: async () => {
const loading = await this.loadingCtrl.create({
message: 'Scaricamento dati da remoto...'
});
await loading.present();
try {
const response = await fetch(`https://api.canticristiani.it/${code}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (response.ok) {
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
// Reconstruct user name/metadata
const userMetadata = remoteJson.find((item: any) => item.momenti && item.momenti.includes('UserMetadata'));
if (userMetadata && userMetadata.titolo) {
this.settingsService.setUserName(userMetadata.titolo);
}
// Reconstruct custom songs
const customSongs = remoteJson
.filter((item: any) => (!item.momenti || !item.momenti.includes('Playlist')) && (!item.momenti || !item.momenti.includes('UserMetadata')))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '',
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '',
link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
if (customSongs.length > 0) {
this.myCantiService.myCanti.set(customSongs);
const storage = this.cantiService.getStorage();
if (storage) {
await storage.set('my-canti', customSongs);
}
}
// Reconstruct playlists
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: String(item.id_canti),
name: item.titolo,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date()
};
});
if (playlists.length > 0) {
this.playlistService.playlists.set(playlists);
if (this.playlistService['_storage']) {
const key = this.playlistService.getPlaylistsStorageKey();
await this.playlistService['_storage'].set(key, playlists);
}
}
}
}
} catch (err) {
console.error('Failed to restore backup data:', err);
} finally {
await loading.dismiss();
}
this.settingsService.setUserUuid(code);
const toast = await this.toastCtrl.create({
message: 'Identità e dati ripristinati con successo! Ricaricamento...',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname);
}, 1500);
}
}
]
});
await alert.present();
}
async restoreOriginalIdentity() {
const original = this.settingsService.originalUserUuid();
if (!original) {
const alert = await this.alertCtrl.create({
header: 'Errore',
message: 'Nessun identificativo originario trovato.',
buttons: ['OK']
});
await alert.present();
return;
}
const alert = await this.alertCtrl.create({
header: 'Ripristina ID Originario',
message: `Sei sicuro di voler ripristinare il codice ID originario assegnato alla prima installazione? L'ID attuale del dispositivo verrà sovrascritto e verranno scaricati eventuali canti e playlist associati all'ID originario.`,
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Ripristina',
handler: () => {
this.confirmRestore(original);
}
}
]
});
await alert.present();
}
async syncLocalDataToServer() {
const uid = this.settingsService.userUuid();
if (!uid) {
const alert = await this.alertCtrl.create({
header: 'Errore',
message: 'Nessun identificativo utente (UID) trovato.',
buttons: ['OK']
});
await alert.present();
return;
}
const toastLoading = await this.toastCtrl.create({
message: 'Invio backup in corso...',
duration: 1500,
color: 'secondary'
});
await toastLoading.present();
try {
await this.playlistService.syncLocalDataToServer();
await toastLoading.dismiss();
const toastSuccess = await this.toastCtrl.create({
message: 'Backup sincronizzato con successo!',
duration: 3000,
color: 'success'
});
await toastSuccess.present();
} catch (err: any) {
try {
await toastLoading.dismiss();
} catch (e) {}
console.error('Failed to backup to server:', err);
const alertError = await this.alertCtrl.create({
header: 'Errore di sincronizzazione',
message: 'Impossibile inviare il backup al server. Controlla la connessione e riprova.',
buttons: ['OK']
});
await alertError.present();
}
}
async restoreBackupFromServer() {
const code = this.settingsService.userUuid();
if (!code) {
const alert = await this.alertCtrl.create({
header: 'Errore',
message: 'Nessun identificativo utente (UID) trovato.',
buttons: ['OK']
});
await alert.present();
return;
}
const alert = await this.alertCtrl.create({
header: 'Ripristina Backup',
message: 'Sei sicuro di voler ripristinare i dati dal server? I tuoi canti personali e le tue scalette locali verranno allineati con l\'ultimo backup presente sul server.',
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Ripristina',
handler: async () => {
const loading = await this.loadingCtrl.create({
message: 'Scaricamento dati da remoto...'
});
await loading.present();
try {
const response = await fetch(`https://api.canticristiani.it/${code}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (response.ok) {
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
// Reconstruct user name/metadata
const userMetadata = remoteJson.find((item: any) => item.momenti && item.momenti.includes('UserMetadata'));
if (userMetadata && userMetadata.titolo) {
this.settingsService.setUserName(userMetadata.titolo);
}
// Reconstruct custom songs
const customSongs = remoteJson
.filter((item: any) => (!item.momenti || !item.momenti.includes('Playlist')) && (!item.momenti || !item.momenti.includes('UserMetadata')))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '',
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '',
link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
this.myCantiService.myCanti.set(customSongs);
const storage = this.cantiService.getStorage();
if (storage) {
await storage.set('my-canti', customSongs);
}
// Reconstruct playlists
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: String(item.id_canti),
name: item.titolo,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date()
};
});
this.playlistService.playlists.set(playlists);
if (this.playlistService['_storage']) {
const key = this.playlistService.getPlaylistsStorageKey();
await this.playlistService['_storage'].set(key, playlists);
}
const toast = await this.toastCtrl.create({
message: 'Dati ripristinati con successo! Ricaricamento...',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname);
}, 1500);
} else {
throw new Error('Formato dati del backup non valido.');
}
} else {
throw new Error('Nessun backup trovato sul server per questo ID.');
}
} catch (err: any) {
console.error('Failed to restore backup data:', err);
const errorAlert = await this.alertCtrl.create({
header: 'Errore Ripristino',
message: err.message || 'Impossibile scaricare il backup dal server. Verifica la connessione.',
buttons: ['OK']
});
await errorAlert.present();
} finally {
await loading.dismiss();
}
}
}
]
});
await alert.present();
}
onModeChange(event: any) {
this.settingsService.setShowChordsDefault(event.detail.value === 'chords');
}
onNameChange(event: any) {
this.settingsService.setUserName(event.target.value);
}
onMassChange(event: any) {
this.cantiLettureService.setSelectedMass(event.detail.value);
}
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);
}
// 3. Refresh community data if a code is active
const comunitaCode = this.comunitaService.comunitaCode();
if (comunitaCode) {
try {
await this.comunitaService.setComunitaCode(comunitaCode);
} catch (err) {
console.error('Failed to refresh community data:', err);
async onComunitaToggleChange(event: any) {
const checked = event.detail.checked;
if (checked) {
this.settingsService.comunitaEnabled.set(true);
localStorage.setItem('comunita-enabled', 'true');
} else {
this.settingsService.comunitaEnabled.set(false);
localStorage.setItem('comunita-enabled', 'false');
}
}
// 4. Check for Service Worker updates
if (this.swUpdate.isEnabled) {
try {
const updateFound = await this.swUpdate.checkForUpdate();
if (updateFound) {
const toast = await this.toastCtrl.create({
message: 'Nuova versione disponibile! Aggiornamento in corso...',
duration: 2000,
color: 'secondary'
async editComunita() {
await this.comunitaService.setComunitaCode('');
}
async saveComunitaCode(code: string) {
const trimmed = (code || '').trim();
if (!trimmed) return;
const loading = await this.loadingCtrl.create({
message: 'Caricamento 0%',
cssClass: 'premium-loading',
spinner: 'crescent'
});
await toast.present();
await loading.present();
setTimeout(() => {
window.location.reload();
}, 2000);
return;
}
} catch (err) {
console.error('Failed to check for updates', err);
}
let 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: 'Dati aggiornati correttamente!',
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();
}
}
async removeComunita() {
await this.comunitaService.setComunitaCode('');
this.settingsService.comunitaEnabled.set(false);
localStorage.setItem('comunita-enabled', 'false');
const toast = await this.toastCtrl.create({
message: 'Comunità disattivata.',
duration: 2000,
color: 'secondary'
});
await toast.present();
}
}
+455 -30
View File
@@ -11,6 +11,22 @@ export class AudioEngineService {
public searchTranscript = signal<string>('');
public isSearching = signal<boolean>(false);
public sensitivity = signal<number>(75); // Default sensitivity (50-100)
public speechRecognitionActive = signal<boolean>(false);
// Voice/Guitar detection signals
public voiceDetected = signal<boolean>(false);
public guitarDetected = signal<boolean>(false);
/** Soglia del punteggio voce: valori più bassi = più sensibile alla voce (range 0.05 0.50) */
public voiceThreshold = signal<number>(0.15);
/** Debug: punteggio voce corrente (0-1) per feedback visivo */
public voiceScore = signal<number>(0);
// Real-time background continuous transcript and word buffer for reliability checks
public backgroundTranscript = signal<string>('');
private backgroundRecognition: any = null;
private recentWordsBuffer: { word: string, timestamp: number }[] = [];
public clearSearchTranscript() {
this.searchTranscript.set('');
@@ -26,6 +42,7 @@ export class AudioEngineService {
private isSpeaking: boolean = false;
private lastSilenceTime: number = Date.now();
private lastWordTime: number = 0;
private speakingStartTime: number = 0;
// Constants for tuning - Optimized for close proximity (singer/guitarist)
private readonly SILENCE_GAP = 100; // ms
@@ -33,11 +50,66 @@ export class AudioEngineService {
private readonly COOLDOWN = 1000; // ms
private peakEnergy: number = 0;
private lineStarted: boolean = false;
private noiseFloor: number = 30; // Adaptive noise floor starting point
// --- Advanced Voice Detection State ---
// Smoothing buffer (16 frames ~260ms a 60fps)
private readonly SMOOTHING_FRAMES = 16;
private voiceScoreBuffer: number[] = [];
// Spectral flux: previous frame spectrum for change detection
private previousSpectrum: Float32Array | null = null;
// Hysteresis: once voice is detected, it stays on for at least this many frames
private readonly VOICE_HOLD_FRAMES = 12; // ~200ms
private voiceHoldCounter: number = 0;
// Sub-band definitions (Hz) - 8 fine-grained bands
private readonly BANDS = [
{ name: 'sub_bass', start: 80, end: 150 }, // Fondamentali basse chitarra (Mi2=82, La2=110)
{ name: 'bass', start: 150, end: 300 }, // Fondamentali voce maschile/femminile + chitarra
{ name: 'low_mid', start: 300, end: 600 }, // Armoniche chitarra dominanti
{ name: 'mid', start: 600, end: 1000 }, // Zona transizione
{ name: 'formant_f1', start: 1000, end: 1500 }, // Formante F1 voce (vocali aperte)
{ name: 'formant_f2', start: 1500, end: 2500 }, // Formante F2 voce (KEY: quasi assente in chitarra)
{ name: 'formant_f3', start: 2500, end: 3500 }, // Formante F3 voce (sibilanti morbide)
{ name: 'sibilants', start: 3500, end: 6000 }, // Consonanti sibilanti (s, t, f, sh) - SOLO voce
];
constructor() {
this.initSpeechRecognition();
}
private initBackgroundRecognition() {
// Disattivato per utilizzare esclusivamente l'analizzatore FFT acustico locale
}
private startBackgroundRecognition() {
// Disattivato
}
private stopBackgroundRecognition() {
// Disattivato
}
private updateRecentWords(transcript: string) {
// Disattivato
}
public getRecentWords(): string[] {
return [];
}
public getRecentWordsSince(timestamp: number): string[] {
return [];
}
public clearRecentWords() {
this.recentWordsBuffer = [];
this.backgroundTranscript.set('');
}
private initSpeechRecognition() {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (SpeechRecognition) {
@@ -84,93 +156,446 @@ export class AudioEngineService {
async startListening() {
if (this.isListening()) return;
this.isListening.set(true);
this.voiceDetected.set(false);
this.guitarDetected.set(false);
this.voiceScoreBuffer = [];
this.previousSpectrum = null;
this.voiceHoldCounter = 0;
this.clearRecentWords();
console.log('[AudioEngine] Starting local FFT Audio Analyser');
await this.startOfflineAnalyser();
}
public async startOfflineAnalyser() {
if (this.analyser) return; // Già attivo
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false // Prevents boosting background noise during silence
autoGainControl: false // Impedisce il boost automatico del rumore di fondo nel silenzio
}
});
this.audioContext = new AudioContext();
const source = this.audioContext.createMediaStreamSource(this.stream);
// FFT a 4096 punti per risoluzione ~10.7 Hz/bin (a 44100 Hz)
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.fftSize = 4096;
this.analyser.smoothingTimeConstant = 0.4; // Smoothing moderato per stabilità spettrale
source.connect(this.analyser);
this.isListening.set(true);
this.processAudio();
} catch (err) {
console.error('Error accessing microphone', err);
console.error('[AudioEngine] Error accessing microphone for offline analyser:', err);
// Se fallisce anche il mic locale, proviamo a ripristinare lo stato
this.stopListening();
alert('Errore microfono: assicurati di usare HTTPS e di aver dato i permessi.');
}
}
stopListening() {
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
// Rilascia le risorse del microfono locale
this.stream?.getTracks().forEach(track => track.stop());
this.audioContext?.close();
this.stream = null;
this.audioContext = null;
this.analyser = null;
this.isListening.set(false);
this.energyLevel.set(0);
this.voiceDetected.set(false);
this.guitarDetected.set(false);
this.voiceScore.set(0);
this.voiceScoreBuffer = [];
this.previousSpectrum = null;
this.voiceHoldCounter = 0;
this.stopBackgroundRecognition();
this.clearRecentWords();
this.speechRecognitionActive.set(false);
}
// =====================================================================
// ANALISI SPETTRALE AVANZATA MULTI-FEATURE
// =====================================================================
/**
* Calcola l'energia media in una sotto-banda dello spettro.
*/
private getBandEnergy(dataArray: Uint8Array, binWidth: number, startHz: number, endHz: number): number {
const startBin = Math.max(0, Math.floor(startHz / binWidth));
const endBin = Math.min(dataArray.length - 1, Math.floor(endHz / binWidth));
if (endBin <= startBin) return 0;
let sum = 0;
for (let i = startBin; i <= endBin; i++) {
sum += dataArray[i];
}
return sum / (endBin - startBin + 1);
}
/**
* FEATURE 1: Rapporto formanti vocali F2+F3 vs bande strumentali (low+mid)
*
* La voce umana ha formanti F2 (1500-2500 Hz) e F3 (2500-3500 Hz) molto prominenti.
* La chitarra acustica ha pochissima energia in queste bande.
* Rapporto alto = voce, basso = strumento.
*/
private calcFormantRatio(bandEnergies: number[]): number {
const instrumentEnergy = bandEnergies[0] + bandEnergies[1] + bandEnergies[2] + bandEnergies[3]; // 80-1000 Hz
const formantEnergy = bandEnergies[5] + bandEnergies[6]; // F2 (1500-2500) + F3 (2500-3500)
return formantEnergy / (instrumentEnergy + 0.001);
}
/**
* FEATURE 2: Energia sibilanti (3500-6000 Hz)
*
* Solo la voce umana produce consonanti sibilanti come "s", "t", "f", "sh", "z"
* che hanno energia significativa sopra 3500 Hz. Gli strumenti acustici (chitarra,
* pianoforte, etc.) hanno energia trascurabile in questa banda.
*
* Ritorna un valore normalizzato 0-1.
*/
private calcSibilantScore(bandEnergies: number[], totalEnergy: number): number {
if (totalEnergy < 1) return 0;
const sibilantEnergy = bandEnergies[7]; // 3500-6000 Hz
// Normalizza: una sibilante tipica ha 15-40% dell'energia totale
return Math.min(1, sibilantEnergy / (totalEnergy * 0.15 + 0.001));
}
/**
* FEATURE 3: Spectral Flatness (entropia di Wiener)
*
* Misura quanto lo spettro è "piatto" (rumoroso) vs "piccato" (tonale).
* - Chitarra: spettro molto tonale con picchi armonici netti flatness BASSA
* - Voce (consonanti): spettro più rumoroso flatness ALTA
* - Voce (vocali): moderatamente tonale ma con formanti larghe flatness MEDIA
*
* Formula: media_geometrica / media_aritmetica (0=tono puro, 1=rumore bianco)
*/
private calcSpectralFlatness(dataArray: Uint8Array, binWidth: number): number {
// Calcola nella regione 300-6000 Hz (dove la discriminazione è più utile)
const startBin = Math.max(1, Math.floor(300 / binWidth));
const endBin = Math.min(dataArray.length - 1, Math.floor(6000 / binWidth));
const n = endBin - startBin + 1;
if (n <= 0) return 0;
let logSum = 0;
let arithmeticSum = 0;
let zeroCount = 0;
for (let i = startBin; i <= endBin; i++) {
const val = Math.max(dataArray[i], 0.001); // Avoid log(0)
logSum += Math.log(val);
arithmeticSum += val;
if (dataArray[i] === 0) zeroCount++;
}
if (zeroCount > n * 0.5) return 0; // Troppi zeri = silenzio
const geometricMean = Math.exp(logSum / n);
const arithmeticMean = arithmeticSum / n;
if (arithmeticMean < 0.001) return 0;
return Math.min(1, geometricMean / arithmeticMean);
}
/**
* FEATURE 4: Spectral Flux (velocità di cambiamento spettrale)
*
* Misura quanto rapidamente cambia la forma dello spettro tra frame successivi.
* - Voce: alta flux (alternanza vocali/consonanti, prosodia)
* - Chitarra: bassa flux (note sostenute, spettro stabile)
*
* Ritorna un valore normalizzato 0-1.
*/
private calcSpectralFlux(dataArray: Uint8Array, binWidth: number): number {
const startBin = Math.max(0, Math.floor(200 / binWidth));
const endBin = Math.min(dataArray.length - 1, Math.floor(5000 / binWidth));
if (!this.previousSpectrum) {
this.previousSpectrum = new Float32Array(dataArray.length);
for (let i = 0; i < dataArray.length; i++) {
this.previousSpectrum[i] = dataArray[i];
}
return 0;
}
let flux = 0;
let maxFlux = 0;
for (let i = startBin; i <= endBin; i++) {
const diff = dataArray[i] - this.previousSpectrum[i];
// Solo variazioni positive (onset) per evitare il decadimento naturale
if (diff > 0) {
flux += diff * diff;
}
maxFlux += 255 * 255; // Massimo teorico
}
// Aggiorna il buffer dello spettro precedente
for (let i = 0; i < dataArray.length; i++) {
this.previousSpectrum[i] = dataArray[i];
}
if (maxFlux === 0) return 0;
// Normalizza e scala logaritmicamente per sensibilità
const normalizedFlux = flux / maxFlux;
return Math.min(1, Math.sqrt(normalizedFlux) * 10); // Amplifica le piccole variazioni
}
/**
* FEATURE 5: Formant Peak Detection
*
* Cerca picchi spettrali caratteristici nella regione delle formanti vocali (800-3500 Hz).
* La voce umana produce 2-4 picchi prominenti (formanti F1-F4).
* La chitarra ha uno spettro armonico regolare senza picchi formantici.
*
* Ritorna il numero di picchi formantici rilevati (0-4), normalizzato 0-1.
*/
private calcFormantPeaks(dataArray: Uint8Array, binWidth: number): number {
const startBin = Math.max(0, Math.floor(800 / binWidth));
const endBin = Math.min(dataArray.length - 1, Math.floor(3500 / binWidth));
// Calcola la media locale per determinare la soglia di prominenza
let sum = 0;
for (let i = startBin; i <= endBin; i++) {
sum += dataArray[i];
}
const avgEnergy = sum / Math.max(1, endBin - startBin + 1);
if (avgEnergy < 5) return 0; // Silenzio
// Cerca picchi: un bin è un picco se è maggiore dei 5 bin a sinistra e 5 a destra
// e supera la media di almeno 30%
const peakThreshold = avgEnergy * 1.3;
const windowSize = Math.max(3, Math.floor(100 / binWidth)); // ~100 Hz di finestra
let peakCount = 0;
let lastPeakBin = -windowSize * 2; // Evita di contare picchi troppo vicini
for (let i = startBin + windowSize; i <= endBin - windowSize; i++) {
if (dataArray[i] < peakThreshold) continue;
let isPeak = true;
for (let j = 1; j <= windowSize; j++) {
if (dataArray[i] < dataArray[i - j] || dataArray[i] < dataArray[i + j]) {
isPeak = false;
break;
}
}
if (isPeak && (i - lastPeakBin) > windowSize) {
peakCount++;
lastPeakBin = i;
if (peakCount >= 4) break; // Max 4 formanti
}
}
return Math.min(1, peakCount / 3); // 3 formanti = punteggio pieno
}
/**
* FEATURE 6: Rapporto energia alta/bassa (spectral tilt)
*
* La voce umana (soprattutto da vicino al microfono) ha uno spettro più "piatto"
* con energia significativa anche nelle alte frequenze.
* La chitarra ha una forte caduta sopra 1-2 kHz.
*/
private calcSpectralTilt(bandEnergies: number[]): number {
const lowEnergy = bandEnergies[0] + bandEnergies[1] + bandEnergies[2]; // 80-600 Hz
const highEnergy = bandEnergies[5] + bandEnergies[6] + bandEnergies[7]; // 1500-6000 Hz
if (lowEnergy < 0.001) return 0;
// Un rapporto alto indica più energia nelle alte frequenze (voce)
return Math.min(1, highEnergy / (lowEnergy + 0.001));
}
/**
* Calcola il punteggio voce combinato da tutte le feature.
*
* Pesi delle feature (calibrati per massimizzare la discriminazione):
* - Formant ratio: 25% (molto discriminante)
* - Sibilant score: 20% (quasi esclusivo della voce)
* - Spectral flatness: 15% (voce più rumorosa di chitarra)
* - Spectral flux: 15% (voce cambia più rapidamente)
* - Formant peaks: 15% (struttura formanti unica della voce)
* - Spectral tilt: 10% (distribuzione energia)
*/
private calcVoiceScore(
dataArray: Uint8Array,
bandEnergies: number[],
totalEnergy: number,
binWidth: number
): number {
const formantRatio = this.calcFormantRatio(bandEnergies);
const sibilantScore = this.calcSibilantScore(bandEnergies, totalEnergy);
const flatness = this.calcSpectralFlatness(dataArray, binWidth);
const flux = this.calcSpectralFlux(dataArray, binWidth);
const formantPeaks = this.calcFormantPeaks(dataArray, binWidth);
const spectralTilt = this.calcSpectralTilt(bandEnergies);
// Normalizza formantRatio: tipicamente 0.05-0.5 per voce, 0-0.05 per chitarra
const normalizedFormantRatio = Math.min(1, formantRatio / 0.4);
// Punteggio pesato
const score =
normalizedFormantRatio * 0.25 +
sibilantScore * 0.20 +
flatness * 0.15 +
flux * 0.15 +
formantPeaks * 0.15 +
spectralTilt * 0.10;
return Math.min(1, Math.max(0, score));
}
// =====================================================================
// MAIN AUDIO PROCESSING LOOP
// =====================================================================
private processAudio() {
if (!this.analyser) return;
const bufferLength = this.analyser.frequencyBinCount;
const bufferLength = this.analyser.frequencyBinCount; // 2048 con fftSize=4096
const dataArray = new Uint8Array(bufferLength);
const sampleRate = this.audioContext ? this.audioContext.sampleRate : 44100;
const binWidth = sampleRate / 4096; // ~10.7 Hz/bin
// Energia minima per considerare che c'è suono (evita falsi positivi nel silenzio)
const MIN_ENERGY = 5;
const analyze = () => {
this.analyser!.getByteFrequencyData(dataArray);
if (!this.analyser) return;
this.analyser.getByteFrequencyData(dataArray);
// Calculate average energy (volume)
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const avgEnergy = sum / bufferLength;
this.energyLevel.set(avgEnergy);
// Calcola energia in ciascuna delle 8 sotto-bande
const bandEnergies: number[] = this.BANDS.map(band =>
this.getBandEnergy(dataArray, binWidth, band.start, band.end)
);
// Energia totale media
const totalEnergy = bandEnergies.reduce((a, b) => a + b, 0) / bandEnergies.length;
// Feedback visuale (barra energia) — usa la media delle bande vocali per coerenza
const vocalDisplayEnergy = (bandEnergies[4] + bandEnergies[5] + bandEnergies[6]) / 3;
this.energyLevel.set(vocalDisplayEnergy);
const now = Date.now();
// Balanced mapping: 0% -> 220 (very quiet), 100% -> 20 (very sensitive)
const currentThreshold = 220 - (this.sensitivity() * 2.0);
const hasSound = totalEnergy > MIN_ENERGY;
if (avgEnergy > currentThreshold) {
if (avgEnergy > this.peakEnergy) {
this.peakEnergy = avgEnergy;
if (hasSound) {
// Calcola il punteggio voce multi-feature
const rawScore = this.calcVoiceScore(dataArray, bandEnergies, totalEnergy, binWidth);
// Aggiungi al buffer di smoothing
this.voiceScoreBuffer.push(rawScore);
if (this.voiceScoreBuffer.length > this.SMOOTHING_FRAMES) {
this.voiceScoreBuffer.shift();
}
// Media pesata: i frame più recenti contano di più
let weightedSum = 0;
let weightTotal = 0;
for (let i = 0; i < this.voiceScoreBuffer.length; i++) {
const weight = (i + 1); // Peso crescente
weightedSum += this.voiceScoreBuffer[i] * weight;
weightTotal += weight;
}
const smoothedScore = weightedSum / weightTotal;
this.voiceScore.set(smoothedScore);
// Determinazione con isteresi
const threshold = this.voiceThreshold();
if (smoothedScore > threshold) {
// Voce rilevata
this.voiceDetected.set(true);
this.guitarDetected.set(false);
this.voiceHoldCounter = this.VOICE_HOLD_FRAMES;
} else if (this.voiceHoldCounter > 0) {
// Isteresi: mantieni lo stato "voce" per evitare oscillazioni
this.voiceHoldCounter--;
// Lo stato resta quello precedente (voce)
} else {
// Strumento/rumore ambientale
this.voiceDetected.set(false);
this.guitarDetected.set(true);
}
} else {
// Silenzio
this.voiceDetected.set(false);
this.guitarDetected.set(false);
this.voiceScore.set(0);
this.voiceHoldCounter = 0;
}
// --- Logica conteggio parole (solo se voce rilevata) ---
// Tracciamento adattivo del rumore di fondo
if (totalEnergy < this.noiseFloor) {
this.noiseFloor = this.noiseFloor * 0.95 + totalEnergy * 0.05;
} else {
this.noiseFloor = this.noiseFloor * 0.998 + totalEnergy * 0.002;
}
// Soglia di sensibilità definita dall'utente
const userThreshold = 220 - (this.sensitivity() * 2.0);
const currentThreshold = Math.max(userThreshold, this.noiseFloor + 12);
// *** CONTEGGIO PAROLE SOLO QUANDO LA VOCE UMANA È DOMINANTE ***
const isVoice = this.voiceDetected();
if (isVoice && totalEnergy > currentThreshold) {
if (totalEnergy > this.peakEnergy) {
this.peakEnergy = totalEnergy;
}
if (!this.isSpeaking) {
this.isSpeaking = true;
this.peakEnergy = avgEnergy;
this.speakingStartTime = now;
this.peakEnergy = totalEnergy;
this.lastSilenceTime = now;
}
// Se siamo in "speaking" e sentiamo un calo significativo rispetto al picco recente (almeno 30% di calo)
// Questo permette di avanzare anche se c'è rumore di fondo sopra la soglia base.
const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy;
if (this.isSpeaking && dropRatio > 0.35 && this.peakEnergy > currentThreshold * 1.2) {
if (now - this.lastWordTime > this.COOLDOWN) {
// Rilevamento della caduta di energia relativa per stacchi sillabici
const dropRatio = (this.peakEnergy - totalEnergy) / this.peakEnergy;
if (this.isSpeaking && dropRatio > 0.20 && this.peakEnergy > currentThreshold * 1.1) {
const speakDuration = now - this.speakingStartTime;
if (now - this.lastWordTime > this.COOLDOWN && speakDuration > 120) {
this.linesDetected.update(v => v + 1);
this.lastWordTime = now;
this.peakEnergy = avgEnergy; // Reset peak
this.peakEnergy = totalEnergy;
this.isSpeaking = false;
console.log('Line advanced - relative drop detected', { dropRatio, avgEnergy, peak: this.peakEnergy });
console.log('[VoiceDetect] Word counted - voice energy drop', {
score: this.voiceScore().toFixed(3),
dropRatio: dropRatio.toFixed(2),
duration: speakDuration
});
}
}
this.lastSilenceTime = now;
} else {
// Sotto soglia (silenzio per il sistema)
if (this.isSpeaking && (now - this.lastSilenceTime > 200)) {
if (now - this.lastWordTime > this.COOLDOWN) {
// Sotto soglia, chitarra, o silenzio
if (this.isSpeaking && (now - this.lastSilenceTime > 180)) {
// Calcola la durata reale del segnale vocale escludendo la finestra di silenzio (180ms)
const actualSoundDuration = this.lastSilenceTime - this.speakingStartTime;
if (isVoice && now - this.lastWordTime > this.COOLDOWN && actualSoundDuration > 100) {
this.linesDetected.update(v => v + 1);
this.lastWordTime = now;
console.log('Line advanced - silence detected');
console.log('[VoiceDetect] Word counted - voice silence gap', { duration: actualSoundDuration });
}
this.isSpeaking = false;
this.peakEnergy = 0;
}
// Se la chitarra è dominante, resetta lo stato di parlato
if (!isVoice && this.isSpeaking) {
this.isSpeaking = false;
this.peakEnergy = 0;
}
}
this.animationFrame = requestAnimationFrame(analyze);
+1 -1
View File
@@ -45,7 +45,7 @@ export class CantiLettureService {
public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso
public suggestionsMomentsMap = signal<Map<number, string[]>>(new Map()); // id_canto -> moments[]
private JSON_URL = 'http://185.193.67.105:3000/cantiletture.json';
private JSON_URL = 'https://api.canticristiani.it/cantiletture.json';
private SECURE_JSON_URL = '/api/cantiletture.json';
constructor() {
+27 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, signal, inject } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Storage } from '@ionic/storage-angular';
import { SettingsService } from './settings.service';
export interface Canto {
id: string;
@@ -13,6 +14,7 @@ export interface Canto {
id_momenti?: number[];
data_update?: string;
nonValidato?: boolean;
isPersonal?: boolean;
}
export interface Indice {
@@ -33,6 +35,7 @@ export interface CantoEseguito {
export class CantiService {
private http = inject(HttpClient);
private storage = inject(Storage);
private settingsService = inject(SettingsService);
private _storage: Storage | null = null;
public canti = signal<Canto[]>([]);
@@ -43,6 +46,7 @@ export class CantiService {
public momenti = signal<Indice[]>([]);
public loading = signal<boolean>(false);
public progress = signal<number>(0);
public firstLoadCompleted = signal<boolean>(false);
private API_URL = 'https://www.canticristiani.it/api/canti.json';
@@ -54,6 +58,20 @@ export class CantiService {
const storage = await this.storage.create();
this._storage = storage;
await this.loadFromStorage();
if (this.canti() && this.canti().length > 0) {
this.firstLoadCompleted.set(true);
} else {
// First boot or data cleared: show setup loader immediately
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Setup in corso',
phase: 'Fase: Setup',
desc: 'Configurazione iniziale e caricamento canti...',
percent: 0
});
}
}
this.refresh();
}
@@ -80,10 +98,16 @@ export class CantiService {
}).subscribe({
next: async (event: any) => {
if (event.type === HttpEventType.DownloadProgress) {
let pct = 0;
if (event.total) {
this.progress.set(Math.round((event.loaded / event.total) * 100));
pct = Math.round((event.loaded / event.total) * 100);
this.progress.set(pct);
} else {
this.progress.update(p => p < 90 ? p + 5 : p);
pct = this.progress();
}
if (!this.firstLoadCompleted() && (window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: pct });
}
} else if (event.type === HttpEventType.Response) {
const response = event.body;
@@ -131,11 +155,13 @@ export class CantiService {
}
this.progress.set(100);
this.loading.set(false);
this.firstLoadCompleted.set(true);
}
},
error: (error) => {
console.error('Failed to fetch canti', error);
this.loading.set(false);
this.firstLoadCompleted.set(true);
}
});
}
+2 -1
View File
@@ -186,7 +186,8 @@ export class ComunitaService {
link_youtube: cp.link_youtube || '',
id_momenti: [],
data_update: cp.data_update || '',
nonValidato: true
nonValidato: Number(cp.stato) === 10,
isPersonal: true
}));
this.comunitaCode.set(trimmedCode);
+215
View File
@@ -0,0 +1,215 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class FaceDetectorService {
public isCameraActive = signal<boolean>(false);
public currentTiltAngle = signal<number>(0);
public isTilted = signal<boolean>(false);
private stream: MediaStream | null = null;
private camera: any = null;
private faceMesh: any = null;
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
// Gesture state machine
private tiltStartTime: number = 0;
private inCooldown: boolean = false;
private readonly TILT_THRESHOLD = 15; // Degrees to trigger next/prev page
private readonly TILT_HOLD_MS = 300; // How long to hold the tilt
private readonly RETURN_THRESHOLD = 6; // Degrees to reset cooldown
constructor() {}
/**
* Loads MediaPipe scripts dynamically if not already loaded.
*/
private loadScripts(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).FaceMesh && (window as any).Camera) {
resolve();
return;
}
const cameraScript = document.createElement('script');
cameraScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js';
cameraScript.crossOrigin = 'anonymous';
const faceMeshScript = document.createElement('script');
faceMeshScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js';
faceMeshScript.crossOrigin = 'anonymous';
cameraScript.onload = () => {
document.head.appendChild(faceMeshScript);
};
faceMeshScript.onload = () => {
resolve();
};
cameraScript.onerror = (err) => reject(err);
faceMeshScript.onerror = (err) => reject(err);
document.head.appendChild(cameraScript);
});
}
/**
* Starts camera capture and face mesh tracking.
*/
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> {
if (this.isCameraActive()) return;
this.onTiltCallback = onTilt;
try {
await this.loadScripts();
// Request camera permissions and stream
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 320 },
height: { ideal: 240 },
facingMode: 'user'
},
audio: false
});
videoElement.srcObject = this.stream;
videoElement.setAttribute('playsinline', 'true');
videoElement.muted = true;
videoElement.play();
const FaceMeshLib = (window as any).FaceMesh;
const CameraLib = (window as any).Camera;
if (!FaceMeshLib || !CameraLib) {
throw new Error('MediaPipe libraries failed to initialize.');
}
this.faceMesh = new FaceMeshLib({
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
});
this.faceMesh.setOptions({
maxNumFaces: 1,
refineLandmarks: false,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.6
});
this.faceMesh.onResults((results: any) => {
this.processLandmarks(results);
});
this.camera = new CameraLib(videoElement, {
onFrame: async () => {
if (this.isCameraActive() && this.faceMesh) {
await this.faceMesh.send({ image: videoElement });
}
},
width: 320,
height: 240
});
this.isCameraActive.set(true);
await this.camera.start();
console.log('[FaceDetector] Face tracking started successfully.');
} catch (err) {
console.error('[FaceDetector] Failed to start face tracking:', err);
this.stop();
throw err;
}
}
/**
* Processes landmarks to calculate head tilt angle.
*/
private processLandmarks(results: any) {
if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
this.currentTiltAngle.set(0);
this.isTilted.set(false);
return;
}
const landmarks = results.multiFaceLandmarks[0];
// Left eye corner (landmark 33) and right eye corner (landmark 263)
const leftEye = landmarks[33];
const rightEye = landmarks[263];
if (!leftEye || !rightEye) return;
// Calculate angle in degrees
const dy = rightEye.y - leftEye.y;
const dx = rightEye.x - leftEye.x;
// Normalize angle (roll)
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
// Smooth angle updates
this.currentTiltAngle.set(Math.round(angle));
const absAngle = Math.abs(angle);
if (absAngle > this.TILT_THRESHOLD) {
this.isTilted.set(true);
if (!this.inCooldown) {
if (this.tiltStartTime === 0) {
this.tiltStartTime = Date.now();
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
// Trigger the tilt gesture!
const direction = angle > 0 ? 'prev' : 'next';
console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`);
if (this.onTiltCallback) {
this.onTiltCallback(direction);
}
this.inCooldown = true;
this.tiltStartTime = 0;
}
}
} else {
this.isTilted.set(false);
this.tiltStartTime = 0;
// Reset cooldown when the head returns near the center
if (absAngle < this.RETURN_THRESHOLD) {
this.inCooldown = false;
}
}
}
/**
* Stops camera capture and releases face mesh resources.
*/
stop() {
this.isCameraActive.set(false);
this.currentTiltAngle.set(0);
this.isTilted.set(false);
this.inCooldown = false;
this.tiltStartTime = 0;
if (this.camera) {
try {
this.camera.stop();
} catch (e) {}
this.camera = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.faceMesh) {
try {
this.faceMesh.close();
} catch (e) {}
this.faceMesh = null;
}
this.onTiltCallback = null;
console.log('[FaceDetector] Face tracking stopped.');
}
}
@@ -0,0 +1,104 @@
import { TestBed } from '@angular/core/testing';
import { LyricsParserService } from './lyrics-parser.service';
import { SettingsService } from './settings.service';
describe('LyricsParserService - Hybrid Notation', () => {
let service: LyricsParserService;
let mockSettingsService: any;
beforeEach(() => {
mockSettingsService = {
chordNotationPreference: jasmine.createSpy('chordNotationPreference').and.returnValue('diesis')
};
TestBed.configureTestingModule({
providers: [
LyricsParserService,
{ provide: SettingsService, useValue: mockSettingsService }
]
});
service = TestBed.inject(LyricsParserService);
});
it('should parse simple text with memorize, replay, copy, paste', () => {
const rawSong = `
{start_verse}{memorize/m1}
Ralle[RE]griamoci, [LA]
non [SOL]cè spazio alla tri[RE]stezza in questo gior[LA]no [SOL]
{end_verse}
{start_chorus}{copy/rit}
Gloria a [RE]Te Emma[SOL]nue-[LA]le
{end_chorus}
{start_verse}{replay/m1}
Rallegri*amoci,*
Egli *viene a libe*rarci da ogni *male.*
{end_verse}
{start_chorus}{paste/rit}
{end_chorus}
`;
// 1. Test parsing with chords enabled
const sections = service.parseAccordi(rawSong);
expect(sections.length).toBe(4);
// Verse 1 (memorize)
expect(sections[0].type).toBe('verse');
expect(sections[0].lines[0].text).toBe('Rallegriamoci, ');
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
expect(sections[0].lines[0].segments[1].chord).toBe('RE');
expect(sections[0].lines[0].segments[2].chord).toBe('LA');
// Chorus 1 (copy)
expect(sections[1].type).toBe('chorus');
expect(sections[1].lines[0].text).toBe('Gloria a Te Emmanue-le');
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
expect(sections[1].lines[0].segments[1].chord).toBe('RE');
expect(sections[1].lines[0].segments[2].chord).toBe('SOL');
expect(sections[1].lines[0].segments[3].chord).toBe('LA');
// Verse 2 (replay)
expect(sections[2].type).toBe('verse');
expect(sections[2].lines[0].text).toBe('Rallegriamoci,');
expect(sections[2].lines[0].segments[0].chord).toBeUndefined();
expect(sections[2].lines[0].segments[1].chord).toBe('RE');
expect(sections[2].lines[0].segments[2].chord).toBe('LA');
expect(sections[2].lines[1].text).toBe('Egli viene a liberarci da ogni male.');
expect(sections[2].lines[1].segments[0].chord).toBeUndefined();
expect(sections[2].lines[1].segments[1].chord).toBe('SOL');
expect(sections[2].lines[1].segments[2].chord).toBe('RE');
expect(sections[2].lines[1].segments[3].chord).toBe('LA');
expect(sections[2].lines[1].segments[4].chord).toBe('SOL');
// Chorus 2 (paste)
expect(sections[3].type).toBe('chorus');
expect(sections[3].lines[0].text).toBe('Gloria a Te Emmanue-le');
expect(sections[3].lines[0].segments[0].chord).toBeUndefined();
expect(sections[3].lines[0].segments[1].chord).toBe('RE');
expect(sections[3].lines[0].segments[2].chord).toBe('SOL');
expect(sections[3].lines[0].segments[3].chord).toBe('LA');
});
it('should strip chords in text-only mode and remove asterisks', () => {
const rawSong = `
{start_verse}{memorize/m1}
Ralle[RE]griamoci, [LA]
{end_verse}
{start_verse}{replay/m1}
Rallegri*amoci,*
{end_verse}
`;
const sections = service.parseText(rawSong);
expect(sections.length).toBe(2);
expect(sections[0].lines[0].text).toBe('Rallegriamoci,');
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
});
});
+144 -26
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { SettingsService } from './settings.service';
export interface ChordSegment {
text: string;
@@ -20,6 +21,7 @@ export interface ParsedSection {
providedIn: 'root'
})
export class LyricsParserService {
private settingsService = inject(SettingsService);
/**
* Parse plain text (campo 'testo') into structured sections.
@@ -43,36 +45,77 @@ export class LyricsParserService {
let currentLines: ParsedLine[] = [];
let verseNumCounter = 0;
const memories = new Map<string, string[]>();
const copies = new Map<string, string[]>();
let currentAction: { type: 'memorize' | 'replay' | 'copy' | 'paste'; key: string } | null = null;
let currentRawLines: string[] = [];
const lines = raw.split('\n');
const startTagRegex = /^\{(start_verse|start_chorus|start_verse_num|sov|soc)\}(?:\{([a-z]+)\/([a-zA-Z0-9_-]+)\})?$/;
for (const line of lines) {
const trimmed = line.trim();
// Detect section start tags
if (trimmed === '{start_verse}' || trimmed === '{sov}') {
const startMatch = trimmed.match(startTagRegex);
if (startMatch) {
this.pushSection(sections, currentType, currentLines);
const tag = startMatch[1];
if (tag === 'start_verse' || tag === 'sov') {
currentType = 'verse';
currentLines = [];
continue;
}
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
this.pushSection(sections, currentType, currentLines);
} else if (tag === 'start_chorus' || tag === 'soc') {
currentType = 'chorus';
currentLines = [];
continue;
}
if (trimmed === '{start_verse_num}') {
this.pushSection(sections, currentType, currentLines);
} else if (tag === 'start_verse_num') {
currentType = 'verse_num';
verseNumCounter++;
}
currentLines = [];
currentRawLines = [];
if (startMatch[2] && startMatch[3]) {
currentAction = {
type: startMatch[2] as any,
key: startMatch[3]
};
if (currentAction.type === 'paste') {
const pastedRawLines = copies.get(currentAction.key) || [];
for (const pLine of pastedRawLines) {
if (withChords) {
currentLines.push(this.parseChordLine(pLine));
} else {
const cleanLine = pLine.replace(/\[[^\]]*\]/g, '').trim();
if (cleanLine.length > 0) {
currentLines.push({
text: cleanLine,
segments: [{ text: cleanLine }]
});
}
}
}
}
} else {
currentAction = null;
}
continue;
}
// Detect section end tags — just skip them
// Detect section end tags
if (trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}') {
if (currentAction) {
if (currentAction.type === 'memorize') {
memories.set(currentAction.key, [...currentRawLines]);
} else if (currentAction.type === 'copy') {
copies.set(currentAction.key, [...currentRawLines]);
}
}
this.pushSection(sections, currentType, currentLines, currentType === 'verse_num' ? verseNumCounter : undefined);
currentLines = [];
currentRawLines = [];
currentAction = null;
continue;
}
@@ -86,12 +129,33 @@ export class LyricsParserService {
continue;
}
let resolvedLine = line;
if (currentAction && currentAction.type === 'replay') {
const memLines = memories.get(currentAction.key) || [];
const lineIndex = currentRawLines.length;
if (lineIndex < memLines.length) {
const memLine = memLines[lineIndex];
const chordMatches = [...memLine.matchAll(/\[([^\]]+)\]/g)].map(m => m[0]);
let chordIdx = 0;
resolvedLine = line.replace(/\*/g, () => {
if (chordIdx < chordMatches.length) {
return chordMatches[chordIdx++];
}
return '';
});
} else {
resolvedLine = line.replace(/\*/g, '');
}
}
currentRawLines.push(line);
// Parse line
if (withChords) {
currentLines.push(this.parseChordLine(line));
currentLines.push(this.parseChordLine(resolvedLine));
} else {
// CLEAN CHORDS in text-only mode: remove [anything]
const cleanLine = line.replace(/\[[^\]]*\]/g, '').trim();
const cleanLine = resolvedLine.replace(/\[[^\]]*\]/g, '').trim();
if (cleanLine.length > 0) {
currentLines.push({
text: cleanLine,
@@ -147,7 +211,7 @@ export class LyricsParserService {
}
// Add the chord as a new segment (text will be filled by next text chunk)
segments.push({ chord: match[1], text: '' });
segments.push({ chord: this.transposeChord(match[1], 0), text: '' });
lastIndex = match.index + match[0].length;
}
@@ -180,7 +244,7 @@ export class LyricsParserService {
* Handles Italian notation.
*/
transposeChord(chord: string, semitones: number): string {
if (!chord || semitones === 0) return chord;
if (!chord) return chord;
// Handle slash chords (e.g., DO/SOL)
if (chord.includes('/')) {
@@ -193,8 +257,9 @@ export class LyricsParserService {
let root = '';
let suffix = '';
const upperChord = chord.toUpperCase();
for (const r of possibleRoots) {
if (chord.startsWith(r)) {
if (upperChord.startsWith(r.toUpperCase())) {
root = r;
suffix = chord.substring(r.length);
break;
@@ -203,16 +268,40 @@ export class LyricsParserService {
if (!root) return chord;
let index = this.scale.indexOf(root);
if (index === -1) index = this.flatScale.indexOf(root);
const upperRoot = root.toUpperCase();
let index = this.scale.indexOf(upperRoot);
if (index === -1) {
index = this.flatScale.findIndex(n => n.toUpperCase() === upperRoot);
}
if (index === -1) return chord;
let newIndex = (index + semitones) % 12;
if (newIndex < 0) newIndex += 12;
// Preserve the original notation style (sharp or flat) if possible
const useFlat = this.flatScale.includes(root);
const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
// Decide flat vs sharp notation based on SettingsService preference:
const pref = this.settingsService.chordNotationPreference();
let useFlat = false;
if (pref === 'diesis') {
useFlat = false;
} else if (pref === 'bemolle') {
useFlat = true;
} else {
// Fallback/Default logic
if (root.includes('#')) {
useFlat = false;
} else if (root.toLowerCase().includes('b')) {
useFlat = true;
} else {
useFlat = semitones < 0;
}
}
let newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
if (newRoot === 'LA#') {
newRoot = 'SIb';
} else if (newRoot === 'RE#') {
newRoot = 'MIb';
}
return newRoot + suffix;
}
@@ -221,8 +310,6 @@ export class LyricsParserService {
* Transpose all chords in a parsed structure.
*/
transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] {
if (semitones === 0) return sections;
return sections.map(section => ({
...section,
lines: section.lines.map(line => ({
@@ -234,4 +321,35 @@ export class LyricsParserService {
}))
}));
}
/**
* Helper to check if the segment at the given index is contiguous with the next segment.
* This means the text is split within a word (e.g. by a chord tag like "rima[RE]ne").
*/
isContiguousNext(segments: ChordSegment[], index: number): boolean {
if (!segments || index >= segments.length - 1) return false;
// Find the next segment with non-empty text
let nextWithText: ChordSegment | null = null;
for (let i = index + 1; i < segments.length; i++) {
if (segments[i].text && segments[i].text.length > 0) {
nextWithText = segments[i];
break;
}
}
const currentText = segments[index].text || '';
if (!currentText) {
// If current segment has no text, it should have no margin-right to not add extra spacing
return true;
}
if (!nextWithText) return false;
const endsWithNonSpace = !/\s$/.test(currentText);
const startsWithNonSpace = !/^\s/.test(nextWithText.text);
return endsWithNonSpace && startsWithNonSpace;
}
}
+60 -4
View File
@@ -1,8 +1,9 @@
import { Injectable, signal, inject } from '@angular/core';
import { Injectable, signal, inject, Injector } from '@angular/core';
import { Storage } from '@ionic/storage-angular';
import { Canto, CantiService } from './canti.service';
import { ToastController } from '@ionic/angular';
import { environment } from '../../environments/environment';
import { PlaylistService } from './playlist.service';
@Injectable({
providedIn: 'root'
@@ -11,6 +12,8 @@ export class MyCantiService {
private storage = inject(Storage);
private cantiService = inject(CantiService);
private toastController = inject(ToastController);
private injector = inject(Injector);
private playlistService!: PlaylistService;
private _storage: Storage | null = null;
public myCanti = signal<Canto[]>([]);
@@ -32,9 +35,47 @@ export class MyCantiService {
}
}
async saveCanto(canto: Partial<Canto>) {
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
const current = this.myCanti();
const newCanto: Canto = {
let updated: Canto[];
let targetCanto: Canto;
if (canto.id && canto.id.startsWith('my_')) {
// Update existing song
updated = current.map(c => {
if (c.id === canto.id) {
targetCanto = {
...c,
titolo: canto.titolo || c.titolo,
testo: canto.testo || c.testo,
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
autore: canto.autore !== undefined ? canto.autore : c.autore,
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
id_momenti: canto.id_momenti || c.id_momenti
};
return targetCanto;
}
return c;
});
// Fallback if not found in list (should not happen normally)
if (!updated.some(c => c.id === canto.id)) {
const numericId = canto.id ? Number(canto.id.replace('my_', '')) : NaN;
const idCanti = isNaN(numericId) ? Date.now() : numericId;
targetCanto = {
id: canto.id,
id_canti: canto.id_canti || idCanti,
titolo: canto.titolo || 'Senza Titolo',
testo: canto.testo || '',
accordi: canto.accordi,
autore: canto.autore,
link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || []
};
updated.push(targetCanto);
}
} else {
// Create new song
targetCanto = {
id: `my_${Date.now()}`,
id_canti: Date.now(), // Fake ID for internal logic
titolo: canto.titolo || 'Senza Titolo',
@@ -44,23 +85,38 @@ export class MyCantiService {
link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || []
};
updated = [...current, targetCanto];
}
const updated = [...current, newCanto];
this.myCanti.set(updated);
await this._storage?.set('my-canti', updated);
// Sincronizza automaticamente in background
if (!this.playlistService) {
this.playlistService = this.injector.get(PlaylistService);
}
this.playlistService.syncLocalDataToServer();
const toast = await this.toastController.create({
message: 'Canto salvato nei "Miei Canti"!',
duration: 2000,
color: 'success'
});
toast.present();
return targetCanto!;
}
async deleteCanto(id: string) {
const updated = this.myCanti().filter(c => c.id !== id);
this.myCanti.set(updated);
await this._storage?.set('my-canti', updated);
// Sincronizza automaticamente in background
if (!this.playlistService) {
this.playlistService = this.injector.get(PlaylistService);
}
this.playlistService.syncLocalDataToServer();
}
async sendAllMyCanti() {
+425 -17
View File
@@ -1,8 +1,11 @@
import { Injectable, signal, inject, computed, effect } from '@angular/core';
import { Injectable, signal, inject, computed, effect, Injector } from '@angular/core';
import { Storage } from '@ionic/storage-angular';
import { Canto, CantiService } from './canti.service';
import { ComunitaService } from './comunita.service';
import * as QRCode from 'qrcode';
import { ToastController, AlertController } from '@ionic/angular';
import { SettingsService } from './settings.service';
import { MyCantiService } from './my-canti.service';
@Injectable({
providedIn: 'root'
@@ -11,6 +14,11 @@ export class PlaylistService {
private storage = inject(Storage);
private cantiService = inject(CantiService);
private comunitaService = inject(ComunitaService);
private toastCtrl = inject(ToastController);
private alertCtrl = inject(AlertController);
private settingsService = inject(SettingsService);
private injector = inject(Injector);
private myCantiService!: MyCantiService;
public selectionMode = signal<boolean>(false);
public selectedIds = signal<Set<string>>(new Set());
@@ -22,7 +30,12 @@ export class PlaylistService {
public activeListName = signal<string | null>(null);
public activePlaylistId = signal<string | null>(null);
public remotePlaylist = signal<any | null>(null);
public remoteCustomSongs = signal<any[]>([]);
public remoteShareCanti = signal<Canto[]>([]);
private _storage: Storage | null = null;
private initPromise!: Promise<void>;
// Community scalette exposed as playlists (only when community filter is active)
public comunitaPlaylists = computed(() => {
@@ -38,18 +51,24 @@ export class PlaylistService {
}));
});
// Merged list: personal playlists + community scalette
// Merged list: personal playlists + community scalette + remote playlist
public allPlaylists = computed(() => {
const community = this.comunitaPlaylists();
const personal = this.playlists();
const remote = this.remotePlaylist();
let list = [...personal.map(p => ({ ...p, isComunita: false, isRemote: false }))];
if (community.length > 0) {
return [...community, ...personal.map(p => ({ ...p, isComunita: false }))];
list = [...community, ...list];
}
return personal.map(p => ({ ...p, isComunita: false }));
if (remote) {
list = [{ ...remote, isComunita: false, isRemote: true }, ...list];
}
return list;
});
constructor() {
this.init();
this.initPromise = this.init();
// Watch for context changes to dynamically reload the correct playlists
effect(() => {
@@ -62,7 +81,7 @@ export class PlaylistService {
}, { allowSignalWrites: true });
}
private getPlaylistsStorageKey(): string {
getPlaylistsStorageKey(): string {
const code = this.comunitaService.comunitaCode();
const isCommunityActive = this.comunitaService.isFilterActive();
if (code && isCommunityActive) {
@@ -86,6 +105,84 @@ export class PlaylistService {
const lastKey = `lastPlaylist_${key}`;
const last = await this._storage.get(lastKey);
this.lastPlaylist.set(last || null);
// Carica la playlist remota persistita e i relativi canti personalizzati
const remotePl = await this._storage.get('remote_playlist');
if (remotePl) {
this.remotePlaylist.set(remotePl);
}
const remoteSongs = await this._storage.get('remote_custom_songs');
if (remoteSongs) {
this.remoteCustomSongs.set(remoteSongs);
}
const remoteShareSongs = await this._storage.get('remote_share_canti');
if (remoteShareSongs) {
this.remoteShareCanti.set(remoteShareSongs);
}
// Aggiorna in background la playlist remota per sincronizzare eventuali modifiche
if (remotePl) {
this.refreshRemotePlaylist();
}
}
async refreshRemotePlaylist() {
const remotePl = this.remotePlaylist();
if (!remotePl) return;
const parts = remotePl.id.split('_');
if (parts.length < 3) return;
const uid = parts[1];
try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) return;
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
const customSongs = remoteJson
.filter((item: any) => !item.momenti || !item.momenti.includes('Playlist'))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '',
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '',
link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: `remote_${uid}_${item.id_canti}`,
name: `[Remote] ${item.titolo}`,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date(),
isRemote: true
};
});
const updatedPl = playlists.find(p => p.id === remotePl.id);
if (updatedPl) {
this.remotePlaylist.set(updatedPl);
this.remoteCustomSongs.set(customSongs);
await this._storage?.set('remote_playlist', updatedPl);
await this._storage?.set('remote_custom_songs', customSongs);
console.log('[Remote-Sync] Remote playlist and custom songs updated successfully.');
}
}
} catch (err) {
console.warn('[Remote-Sync] Failed to refresh remote playlist:', err);
}
}
toggleSelectionMode() {
@@ -114,7 +211,8 @@ export class PlaylistService {
});
}
async savePlaylist(name: string, ids: string[]) {
async savePlaylist(name: string, ids: string[], songSettings?: any, setAsLast = true) {
await this.initPromise;
const key = this.getPlaylistsStorageKey();
const lastKey = `lastPlaylist_${key}`;
const editId = this.activePlaylistId();
@@ -123,7 +221,8 @@ export class PlaylistService {
if (editId) {
this.playlists.update(p => p.map(pl => {
if (pl.id === editId) {
return { ...pl, name, ids };
const mergedSettings = songSettings || pl.songSettings || {};
return { ...pl, name, ids, songSettings: mergedSettings };
}
return pl;
}));
@@ -133,31 +232,203 @@ export class PlaylistService {
id: Date.now().toString(),
name,
ids,
songSettings: songSettings || {},
createdAt: new Date()
};
this.playlists.update(p => [newPlaylist, ...p]);
}
if (setAsLast) {
this.lastPlaylist.set(newPlaylist);
}
this.activeListIds.set(ids);
this.activeListName.set(name);
await this._storage?.set(key, this.playlists());
if (setAsLast) {
await this._storage?.set(lastKey, newPlaylist);
}
// Reset selection mode, IDs and editing state after saving
this.selectedIds.set(new Set());
this.selectionMode.set(false);
this.activePlaylistId.set(newPlaylist.id);
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async replaceSongIdInPlaylists(oldId: string, newId: string) {
await this.initPromise;
this.playlists.update(p => p.map(pl => {
if (pl.ids && pl.ids.includes(oldId)) {
const updatedIds = pl.ids.map((id: string) => id === oldId ? newId : id);
// Copia anche i parametri di tonalità/zoom/velocità della canzone se presenti
const songSettings = { ...(pl.songSettings || {}) };
if (songSettings[oldId]) {
songSettings[newId] = { ...songSettings[oldId] };
delete songSettings[oldId];
}
return { ...pl, ids: updatedIds, songSettings };
}
return pl;
}));
const key = this.getPlaylistsStorageKey();
await this._storage?.set(key, this.playlists());
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number, zoom?: number) {
await this.initPromise;
this.playlists.update(p => p.map(pl => {
if (pl.id === playlistId) {
const songSettings = { ...(pl.songSettings || {}) };
songSettings[songId] = {
tonalita,
speed,
zoom: zoom !== undefined ? zoom : songSettings[songId]?.zoom
};
return { ...pl, songSettings };
}
return pl;
}));
const key = this.getPlaylistsStorageKey();
await this._storage?.set(key, this.playlists());
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
// Also update lastPlaylist if it is the current one
const lastKey = `lastPlaylist_${key}`;
const last = this.lastPlaylist();
if (last && last.id === playlistId) {
const updatedLast = this.playlists().find(pl => pl.id === playlistId);
this.lastPlaylist.set(updatedLast || null);
await this._storage?.set(lastKey, updatedLast);
}
}
async deletePlaylist(id: string) {
await this.initPromise;
if (id.startsWith('remote_')) {
await this.clearRemotePlaylist();
return;
}
const key = this.getPlaylistsStorageKey();
this.playlists.update(p => p.filter(pl => pl.id !== id));
await this._storage?.set(key, this.playlists());
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async generateQR(ids: string[], name: string): Promise<string> {
const data = this.getShareLink(ids, name);
async saveRemotePlaylist(pl: any, customSongs: any[]) {
await this.initPromise;
this.remotePlaylist.set(pl);
this.remoteCustomSongs.set(customSongs);
await this._storage?.set('remote_playlist', pl);
await this._storage?.set('remote_custom_songs', customSongs);
}
async clearRemotePlaylist() {
await this.initPromise;
this.remotePlaylist.set(null);
this.remoteCustomSongs.set([]);
await this._storage?.remove('remote_playlist');
await this._storage?.remove('remote_custom_songs');
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async syncLocalDataToServer() {
await this.initPromise;
const uid = this.settingsService.userUuid();
if (!uid) return;
if (!this.myCantiService) {
this.myCantiService = this.injector.get(MyCantiService);
}
try {
const mergedSongsMap = new Map<number, any>();
this.remoteShareCanti().forEach(c => {
const id_canti = c.id_canti || Date.now();
mergedSongsMap.set(id_canti, {
id_canti,
titolo: c.titolo || 'Senza Titolo',
autore: c.autore || '',
link_youtube: c.link_youtube || '',
accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[],
testo: c.testo || ''
});
});
this.myCantiService.myCanti().forEach(c => {
const id_canti = c.id_canti || Date.now();
mergedSongsMap.set(id_canti, {
id_canti,
titolo: c.titolo || 'Senza Titolo',
autore: c.autore || '',
link_youtube: c.link_youtube || '',
accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[],
testo: c.testo || ''
});
});
const customSongs = Array.from(mergedSongsMap.values());
const playlistSongs = this.playlists().map(pl => ({
id_canti: Number(pl.id) || Date.now(),
titolo: pl.name,
momenti: ['Playlist'],
periodi: pl.songSettings ? [JSON.stringify(pl.songSettings)] : [],
testo: pl.ids.join(',')
}));
const payload = [...customSongs, ...playlistSongs];
if (this.settingsService.userName()) {
payload.push({
id_canti: 999999,
titolo: this.settingsService.userName(),
momenti: ['UserMetadata'],
periodi: [],
testo: ''
});
}
const response = await fetch('https://api.canticristiani.it/miei', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-uid': uid
},
body: JSON.stringify(payload)
});
if (!response.ok) {
console.warn(`[Auto-Sync] Server returned code: ${response.status}`);
} else {
console.log('[Auto-Sync] Local playlists and canti backed up successfully.');
}
} catch (err) {
console.error('[Auto-Sync] Auto-synchronization failed:', err);
}
}
async generateQR(ids: string[], name: string, songSettings?: any): Promise<string> {
const data = this.getShareLink(ids, name, songSettings);
return await QRCode.toDataURL(data, {
width: 400,
margin: 2,
@@ -168,28 +439,130 @@ export class PlaylistService {
});
}
getShareLink(ids: string[], name: string): string {
const data = JSON.stringify({ name, ids });
getShareLink(ids: string[], name: string, songSettings?: any): string {
const mergedSettings = { ...(songSettings || {}) };
const cc = this.comunitaService.comunitaCode();
const isCommunityActive = this.comunitaService.isFilterActive();
if (cc && isCommunityActive) {
const communitySettings = this.comunitaService.comunitaCantiSettings();
for (const id of ids) {
if (mergedSettings[id] === undefined) {
const cSettings = communitySettings.find(s =>
s.id_canti === Number(id) || String(s.id_canti) === id
);
if (cSettings) {
const tonalita = cSettings.tonalita !== undefined ? cSettings.tonalita : 0;
let speed = 2;
if (cSettings.speed !== undefined && cSettings.speed > 0) {
speed = Math.max(1, Math.min(10, Math.round(cSettings.speed / 100)));
}
mergedSettings[id] = { tonalita, speed };
}
}
}
}
const shareObj: any = { name, ids, songSettings: mergedSettings };
if (cc && isCommunityActive) {
const communityCantiIds = this.comunitaService.comunitaCantiIds();
const communityCantiPersonali = this.comunitaService.comunitaCantiPersonali();
const containsCommunitySong = ids.some(id => {
const isStandard = communityCantiIds.includes(id) || communityCantiIds.includes(Number(id));
const isPersonal = communityCantiPersonali.some(cp => cp.id === id);
return isStandard || isPersonal;
});
if (containsCommunitySong) {
shareObj.comunitaCode = cc;
}
}
const data = JSON.stringify(shareObj);
// Use btoa safely for UTF-8 strings
const base64 = btoa(unescape(encodeURIComponent(data)));
// Always use the production URL for sharing links as requested
const productionUrl = 'https://www.canticristiani.it';
return `${productionUrl}/?import=${base64}`;
return `${productionUrl}/?import=${base64}&openFirst=1`;
}
processImportJson(json: any): boolean {
if (json && json.name && json.ids) {
this.activePlaylistId.set(null); // Forza il salvataggio come nuova playlist indipendente ed editabile
this.activeListIds.set(json.ids);
this.activeListName.set(json.name);
// Automatically save to the device!
this.savePlaylist(json.name, json.ids, json.songSettings, false);
return true;
}
return false;
}
async sharePlaylistQR(ids: string[], name: string) {
const qrImage = await this.generateQR(ids, name);
const shareLink = this.getShareLink(ids, name);
async sharePlaylistQR(ids: string[], name: string, songSettings?: any) {
// Sincronizza istantaneamente sul server prima di generare/condividere la playlist
await this.syncLocalDataToServer();
const uid = this.settingsService.userUuid();
const activeId = this.activePlaylistId() || Date.now().toString();
const isRemote = activeId.startsWith('remote_');
const buttons: any[] = [
{
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
handler: () => {
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
if (isRemote) {
let remoteUid = uid;
let remotePid = activeId;
const parts = activeId.split('_');
if (parts.length >= 3) {
remoteUid = parts[1];
remotePid = parts[2];
}
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
}
this.executeShare(shareLink, name);
}
}
];
if (!isRemote) {
buttons.push({
text: 'Modifica (Collaborazione / Backup)',
handler: () => {
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
this.executeShare(shareLink, name + ' (Editor)');
}
});
}
buttons.push({
text: 'Annulla',
role: 'cancel'
});
const alert = await this.alertCtrl.create({
header: 'Condividi Playlist',
message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:',
buttons: buttons
});
await alert.present();
}
private async executeShare(shareLink: string, name: string) {
// Generate QR using the link
const qrImage = await QRCode.toDataURL(shareLink, {
width: 400,
margin: 2,
color: {
dark: '#2d3436',
light: '#ffffff'
}
});
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
@@ -204,7 +577,21 @@ export class PlaylistService {
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
});
} else {
// Fallback: download
// Fallback: copia il link negli appunti e scarica l'immagine del QR
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
const toast = await this.toastCtrl.create({
message: 'Link playlist copiato negli appunti! QR Code scaricato.',
duration: 3000,
color: 'success'
});
await toast.present();
}
} catch (clipErr) {
console.warn('Failed to copy link to clipboard:', clipErr);
}
const link = document.createElement('a');
link.href = qrImage;
link.download = fileName;
@@ -217,4 +604,25 @@ export class PlaylistService {
async loadPlaylists() {
}
async saveRemoteShareCanto(canto: Canto) {
await this.initPromise;
const current = this.remoteShareCanti();
const index = current.findIndex(c => c.id === canto.id);
let updated: Canto[];
if (index > -1) {
updated = current.map(c => c.id === canto.id ? canto : c);
} else {
updated = [...current, canto];
}
this.remoteShareCanti.set(updated);
await this._storage?.set('remote_share_canti', updated);
}
async deleteRemoteShareCanto(id: string) {
await this.initPromise;
const updated = this.remoteShareCanti().filter(c => c.id !== id);
this.remoteShareCanti.set(updated);
await this._storage?.set('remote_share_canti', updated);
}
}
+130 -47
View File
@@ -42,10 +42,28 @@ export class SettingsService {
public showUpdateDate = signal<boolean>(true);
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
public enableStandardAutoscroll = signal<boolean>(true);
public enableStandardAutoscroll = signal<boolean>(false);
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
public enableAcousticAutoscroll = signal<boolean>(false);
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
public enableVisualAutoscroll = signal<boolean>(true);
/** Preferenza notazione accordi: diesis o bemolle */
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
/** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */
public karaokePageScrollMode = signal<boolean>(false);
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
public landscapeProjectionEnabled = signal<boolean>(true);
/** Identificativo utente univoco per la gestione delle comunità */
public userUuid = signal<string>('');
/** Identificativo originario assegnato alla prima installazione */
public originalUserUuid = signal<string>('');
/** Nome associato all'identità utente */
public userName = signal<string>('');
private wakeLock: any = null;
@@ -55,8 +73,38 @@ export class SettingsService {
public isStandalone = signal<boolean>(false);
public isIos = signal<boolean>(false);
public isAndroid = signal<boolean>(false);
public isVersionCheckComplete = signal<boolean>(false);
constructor() {
// Gestione/Generazione ID utente univoco
let savedUuid = localStorage.getItem('user-uuid');
if (!savedUuid) {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
savedUuid = crypto.randomUUID();
} else {
savedUuid = '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('user-uuid', savedUuid);
}
this.userUuid.set(savedUuid);
// Salvataggio dell'ID originario (alla prima installazione) se non è già presente
let originalUuid = localStorage.getItem('original-user-uuid');
if (!originalUuid) {
originalUuid = savedUuid;
localStorage.setItem('original-user-uuid', originalUuid);
}
this.originalUserUuid.set(originalUuid);
const savedName = localStorage.getItem('user-name');
if (savedName) {
this.userName.set(savedName);
}
// Detect PWA status
try {
this.isStandalone.set(
@@ -90,7 +138,7 @@ export class SettingsService {
});
// Migration: force settings defaults once for existing users to match the new rules
const migrationKey = 'defaults-migrated-20260521';
const migrationKey = 'defaults-migrated-20260605';
if (localStorage.getItem(migrationKey) !== 'true') {
localStorage.setItem('show-chords-default', 'true');
localStorage.setItem('fullscreen-mode', this.isIos().toString());
@@ -101,8 +149,9 @@ export class SettingsService {
localStorage.setItem('invio-dati-statistici', 'false');
localStorage.setItem('show-tags-in-list', 'true');
localStorage.setItem('show-update-date', 'true');
localStorage.setItem('enable-standard-autoscroll', 'true');
localStorage.setItem('enable-acoustic-autoscroll', 'false');
localStorage.setItem('enable-standard-autoscroll', 'false');
localStorage.setItem('enable-visual-autoscroll', 'true');
localStorage.setItem('chord-notation-preference', 'diesis');
// ThemeService high contrast default
localStorage.setItem('high-contrast', 'true');
@@ -110,6 +159,7 @@ export class SettingsService {
localStorage.setItem(migrationKey, 'true');
}
const savedChords = localStorage.getItem('show-chords-default');
if (savedChords !== null) {
this.showChordsDefault.set(savedChords === 'true');
@@ -138,12 +188,8 @@ export class SettingsService {
this.autoAdvance.set(true);
}
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
if (savedKeepScreenOn !== null) {
this.keepScreenOn.set(savedKeepScreenOn === 'true');
} else {
// Keep screen always on by default and always active
this.keepScreenOn.set(true);
}
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
if (savedComunitaEnabled !== null) {
@@ -177,14 +223,35 @@ export class SettingsService {
if (savedStandardAutoscroll !== null) {
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
} else {
this.enableStandardAutoscroll.set(true);
this.enableStandardAutoscroll.set(false);
}
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
if (savedAcousticAutoscroll !== null) {
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
const savedVisualAutoscroll = localStorage.getItem('enable-visual-autoscroll');
if (savedVisualAutoscroll !== null) {
this.enableVisualAutoscroll.set(savedVisualAutoscroll === 'true');
} else {
this.enableAcousticAutoscroll.set(false);
this.enableVisualAutoscroll.set(true);
}
const savedNotation = localStorage.getItem('chord-notation-preference');
if (savedNotation !== null) {
this.chordNotationPreference.set(savedNotation === 'bemolle' ? 'bemolle' : 'diesis');
} else {
this.chordNotationPreference.set('diesis');
}
const savedKaraokePageScrollMode = localStorage.getItem('karaoke-page-scroll-mode');
if (savedKaraokePageScrollMode !== null) {
this.karaokePageScrollMode.set(savedKaraokePageScrollMode === 'true');
} else {
this.karaokePageScrollMode.set(false);
}
const savedLandscapeProjectionEnabled = localStorage.getItem('landscape-projection-enabled');
if (savedLandscapeProjectionEnabled !== null) {
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
} else {
this.landscapeProjectionEnabled.set(true);
}
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
@@ -210,29 +277,6 @@ export class SettingsService {
effect(() => {
const mode = this.fullscreenMode();
localStorage.setItem('fullscreen-mode', mode.toString());
const isFs = !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
);
if (mode && !isFs) {
const docEl = document.documentElement as any;
if (docEl.requestFullscreen) {
docEl.requestFullscreen().catch((err: any) => console.log('Request fs ignored', err));
} else if (docEl.webkitRequestFullscreen) {
docEl.webkitRequestFullscreen();
}
} else if (!mode && isFs) {
const doc = document as any;
if (doc.exitFullscreen) {
doc.exitFullscreen().catch((err: any) => console.log('Exit fs ignored', err));
} else if (doc.webkitExitFullscreen) {
doc.webkitExitFullscreen();
}
}
});
effect(() => {
@@ -252,7 +296,19 @@ export class SettingsService {
});
effect(() => {
localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString());
localStorage.setItem('enable-visual-autoscroll', this.enableVisualAutoscroll().toString());
});
effect(() => {
localStorage.setItem('chord-notation-preference', this.chordNotationPreference());
});
effect(() => {
localStorage.setItem('karaoke-page-scroll-mode', this.karaokePageScrollMode().toString());
});
effect(() => {
localStorage.setItem('landscape-projection-enabled', this.landscapeProjectionEnabled().toString());
});
effect(() => {
@@ -291,9 +347,6 @@ export class SettingsService {
localStorage.setItem('show-editor', newValue.toString());
}
toggleKeepScreenOn() {
this.keepScreenOn.update(v => !v);
}
toggleComunitaEnabled() {
const newValue = !this.comunitaEnabled();
@@ -375,10 +428,40 @@ export class SettingsService {
localStorage.setItem('enable-standard-autoscroll', newValue.toString());
}
toggleAcousticAutoscroll() {
const newValue = !this.enableAcousticAutoscroll();
this.enableAcousticAutoscroll.set(newValue);
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
toggleVisualAutoscroll() {
const newValue = !this.enableVisualAutoscroll();
this.enableVisualAutoscroll.set(newValue);
localStorage.setItem('enable-visual-autoscroll', newValue.toString());
}
toggleKaraokePageScrollMode() {
const newValue = !this.karaokePageScrollMode();
this.karaokePageScrollMode.set(newValue);
localStorage.setItem('karaoke-page-scroll-mode', newValue.toString());
}
toggleLandscapeProjectionEnabled() {
const newValue = !this.landscapeProjectionEnabled();
this.landscapeProjectionEnabled.set(newValue);
localStorage.setItem('landscape-projection-enabled', newValue.toString());
}
setChordNotationPreference(val: 'diesis' | 'bemolle') {
this.chordNotationPreference.set(val);
}
setUserUuid(uuid: string) {
const trimmed = uuid.trim();
if (trimmed) {
this.userUuid.set(trimmed);
localStorage.setItem('user-uuid', trimmed);
}
}
setUserName(name: string) {
const trimmed = name.trim();
this.userName.set(trimmed);
localStorage.setItem('user-name', trimmed);
}
async installPwa() {
+38 -4
View File
@@ -6,6 +6,9 @@ import { Injectable, signal, effect } from '@angular/core';
export class ThemeService {
public highContrast = signal<boolean>(true);
/** Whether the system prefers dark mode */
private systemPrefersDark = signal<boolean>(false);
constructor() {
// Load from localStorage
const saved = localStorage.getItem('high-contrast');
@@ -15,18 +18,49 @@ export class ThemeService {
this.highContrast.set(true);
}
// Effect to apply class to body
// Detect system dark mode preference
if (typeof window !== 'undefined') {
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
this.systemPrefersDark.set(darkMq.matches);
darkMq.addEventListener('change', (e) => {
this.systemPrefersDark.set(e.matches);
});
}
// Effect to apply high-contrast class to body and html
effect(() => {
const isHigh = this.highContrast();
if (typeof document !== 'undefined' && document.body) {
if (typeof document !== 'undefined') {
const root = document.documentElement;
if (isHigh) {
document.body.classList.add('high-contrast');
root.classList.add('high-contrast');
if (document.body) document.body.classList.add('high-contrast');
} else {
document.body.classList.remove('high-contrast');
root.classList.remove('high-contrast');
if (document.body) document.body.classList.remove('high-contrast');
}
}
localStorage.setItem('high-contrast', isHigh.toString());
});
// Effect to manage Ionic dark palette class
// When high contrast is ON → NEVER apply dark palette (force light mode)
// When high contrast is OFF and system prefers dark → apply dark palette
effect(() => {
const isHigh = this.highContrast();
const systemDark = this.systemPrefersDark();
if (typeof document !== 'undefined') {
const root = document.documentElement;
if (!isHigh && systemDark) {
root.classList.add('ion-palette-dark');
if (document.body) document.body.classList.add('ion-palette-dark');
} else {
root.classList.remove('ion-palette-dark');
if (document.body) document.body.classList.remove('ion-palette-dark');
}
}
});
}
toggleContrast() {
@@ -22,12 +22,6 @@ export class YoutubePlayerService {
if (!this.connectivityService.isOnline()) {
return false;
}
// Check if iPhone/iPad/iPod
const userAgent = window.navigator.userAgent.toLowerCase();
const isIOS = /iphone|ipad|ipod/.test(userAgent);
if (isIOS) {
return false;
}
return true;
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.05.22.0110';
export const VERSION = '2026.06.17.0049';
+3 -1
View File
@@ -1,5 +1,7 @@
export const environment = {
production: true,
contactEmail: 'info@canticristiani.it',
appName: 'CantiCristiani'
appName: 'CantiCristiani',
apiAuthUser: 'canti',
apiAuthPass: 'antani2026'
};
+3 -1
View File
@@ -5,7 +5,9 @@
export const environment = {
production: false,
contactEmail: 'info@canticristiani.it',
appName: 'CantiCristiani'
appName: 'CantiCristiani',
apiAuthUser: 'canti',
apiAuthPass: 'antani2026'
};
/*
+126 -3
View File
@@ -33,8 +33,8 @@
*/
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
@import "@ionic/angular/css/palettes/dark.system.css";
/* @import "@ionic/angular/css/palettes/dark.system.css"; */
@import "@ionic/angular/css/palettes/dark.class.css";
ion-header {
border: none !important;
@@ -66,14 +66,124 @@ ion-app {
}
/* High Contrast Mode Overrides */
html.high-contrast, body.high-contrast {
color-scheme: light !important;
}
body.high-contrast {
--ion-background-color: #ffffff;
--ion-background-color-rgb: 255, 255, 255;
--ion-text-color: #000000;
--ion-text-color-rgb: 0, 0, 0;
/* Primary color - complete set for shadow DOM components */
--ion-color-primary: #000000;
--ion-color-secondary: #e67e22; // A bit darker for readability on white
--ion-color-primary-rgb: 0, 0, 0;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #000000;
--ion-color-primary-tint: #1a1a1a;
/* Secondary color - complete set for shadow DOM components */
--ion-color-secondary: #e67e22;
--ion-color-secondary-rgb: 230, 126, 34;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #cb6f1e;
--ion-color-secondary-tint: #e98b38;
/* Medium color - used by filter chips */
--ion-color-medium: #92949c;
--ion-color-medium-rgb: 146, 148, 156;
--ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #808289;
--ion-color-medium-tint: #9d9fa6;
/* Light color */
--ion-color-light: #f4f5f8;
--ion-color-light-rgb: 244, 245, 248;
--ion-color-light-contrast: #000000;
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
/* Dark color */
--ion-color-dark: #222428;
--ion-color-dark-rgb: 34, 36, 40;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
/* Light mode background step variables (light → dark) */
--ion-background-color-step-50: #f2f2f2;
--ion-background-color-step-100: #e6e6e6;
--ion-background-color-step-150: #d9d9d9;
--ion-background-color-step-200: #cccccc;
--ion-background-color-step-250: #bfbfbf;
--ion-background-color-step-300: #b3b3b3;
--ion-background-color-step-350: #a6a6a6;
--ion-background-color-step-400: #999999;
--ion-background-color-step-450: #8c8c8c;
--ion-background-color-step-500: #808080;
--ion-background-color-step-550: #737373;
--ion-background-color-step-600: #666666;
--ion-background-color-step-650: #595959;
--ion-background-color-step-700: #4d4d4d;
--ion-background-color-step-750: #404040;
--ion-background-color-step-800: #333333;
--ion-background-color-step-850: #262626;
--ion-background-color-step-900: #1a1a1a;
--ion-background-color-step-950: #0d0d0d;
/* Light mode text step variables (dark → light) */
--ion-text-color-step-50: #0d0d0d;
--ion-text-color-step-100: #1a1a1a;
--ion-text-color-step-150: #262626;
--ion-text-color-step-200: #333333;
--ion-text-color-step-250: #404040;
--ion-text-color-step-300: #4d4d4d;
--ion-text-color-step-350: #595959;
--ion-text-color-step-400: #666666;
--ion-text-color-step-450: #737373;
--ion-text-color-step-500: #808080;
--ion-text-color-step-550: #8c8c8c;
--ion-text-color-step-600: #999999;
--ion-text-color-step-650: #a6a6a6;
--ion-text-color-step-700: #b3b3b3;
--ion-text-color-step-750: #bfbfbf;
--ion-text-color-step-800: #cccccc;
--ion-text-color-step-850: #d9d9d9;
--ion-text-color-step-900: #e6e6e6;
--ion-text-color-step-950: #f2f2f2;
/* Legacy step variables (for older Ionic components) */
--ion-color-step-50: #f4f5f8;
--ion-color-step-100: #e0e0e0;
--ion-color-step-150: #dcdcdc;
--ion-color-step-200: #cccccc;
--ion-color-step-250: #bfbfbf;
--ion-color-step-300: #b3b3b3;
--ion-color-step-350: #a6a6a6;
--ion-color-step-400: #999999;
--ion-color-step-450: #8c8c8c;
--ion-color-step-500: #808080;
--ion-color-step-550: #737373;
--ion-color-step-600: #666666;
--ion-color-step-650: #595959;
--ion-color-step-700: #4d4d4d;
--ion-color-step-750: #404040;
--ion-color-step-800: #333333;
--ion-color-step-850: #262626;
--ion-color-step-900: #191919;
--ion-color-step-950: #0d0d0d;
/* Reset component-specific dark mode variables */
--ion-item-background: #ffffff;
--ion-card-background: #ffffff;
--ion-toolbar-background: #ffffff;
--ion-tab-bar-background: #ffffff;
.bg-gradient {
background: #ffffff !important;
@@ -263,6 +373,19 @@ body.high-contrast {
color: #000000 !important;
}
}
/* Prevent button color/background issues in active/focus/hover states in high contrast */
ion-button {
--color-activated: var(--color) !important;
--color-focused: var(--color) !important;
--color-hover: var(--color) !important;
&[fill="clear"], &[fill="outline"] {
--background-activated: rgba(0, 0, 0, 0.1) !important;
--background-focused: rgba(0, 0, 0, 0.08) !important;
--background-hover: rgba(0, 0, 0, 0.05) !important;
}
}
}
.offline-badge-header {
+168
View File
@@ -7,6 +7,14 @@
<base href="/"/>
<!-- SEO & OpenGraph Meta Tags -->
<meta name="description" content="Testi e accordi di canti cristiani e liturgici, sempre con te anche offline." />
<meta property="og:title" content="Canti Cristiani" />
<meta property="og:description" content="Testi e accordi di canti cristiani e liturgici, sempre con te anche offline." />
<meta property="og:image" content="assets/icon/favicon.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.canticristiani.it/" />
<meta name="color-scheme" content="light dark"/>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<meta name="format-detection" content="telephone=no"/>
@@ -26,8 +34,168 @@
</head>
<body>
<div id="pwa-boot-loader" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif; transition: opacity 0.5s ease;">
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div>
</div>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script>
(function() {
// 1. Loader API definition
window.PwaLoader = {
update: function(options) {
const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc');
const phaseEl = document.getElementById('pwa-boot-phase');
const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent');
const versionEl = document.getElementById('pwa-boot-version');
if (options.title && titleEl) titleEl.textContent = options.title;
if (options.desc && descEl) descEl.textContent = options.desc;
if (options.phase && phaseEl) phaseEl.textContent = options.phase;
if (options.version && versionEl) versionEl.textContent = options.version;
if (options.percent !== undefined) {
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
if (bar) bar.style.width = pct + '%';
if (pctText) pctText.textContent = pct + '%';
}
if (options.isRedirect) {
if (bar && bar.parentElement) bar.parentElement.style.display = 'none';
if (pctText) pctText.style.display = 'none';
if (phaseEl) phaseEl.style.display = 'none';
if (versionEl) versionEl.style.display = 'none';
if (titleEl) {
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device';
titleEl.style.fontSize = '1.4rem';
titleEl.style.lineHeight = '1.5';
}
if (descEl) descEl.style.display = 'none';
}
},
hide: function() {
const loader = document.getElementById('pwa-boot-loader');
if (loader) {
loader.style.opacity = '0';
setTimeout(() => {
if (loader.parentNode) {
loader.parentNode.removeChild(loader);
}
}, 500);
}
},
show: function() {
let loader = document.getElementById('pwa-boot-loader');
if (!loader) {
loader = document.createElement('div');
loader.id = 'pwa-boot-loader';
loader.style.position = 'fixed';
loader.style.top = '0';
loader.style.left = '0';
loader.style.width = '100vw';
loader.style.height = '100vh';
loader.style.background = 'radial-gradient(circle at top left, #2d3436 0%, #121212 100%)';
loader.style.color = '#ffffff';
loader.style.display = 'flex';
loader.style.flexDirection = 'column';
loader.style.justifyContent = 'center';
loader.style.alignItems = 'center';
loader.style.zIndex = '99999';
loader.style.fontFamily = "'Outfit', sans-serif";
loader.style.transition = 'opacity 0.5s ease';
loader.style.opacity = '0';
loader.innerHTML = `
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div>
`;
document.body.appendChild(loader);
loader.offsetHeight; // Force reflow
loader.style.opacity = '1';
}
}
};
// 2. Setup initial labels
const isUpdate = window.location.search.includes('update');
const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc');
const phaseEl = document.getElementById('pwa-boot-phase');
const isFirstInstall = 'serviceWorker' in navigator && !navigator.serviceWorker.controller;
if (isUpdate) {
if (titleEl) titleEl.textContent = 'Aggiornamento completato';
if (descEl) descEl.textContent = 'Ottimizzazione e avvio della nuova versione...';
if (phaseEl) phaseEl.textContent = 'Fase: Avvio';
} else if (isFirstInstall) {
if (titleEl) titleEl.textContent = 'Download in corso';
if (descEl) descEl.textContent = 'Scaricamento dei componenti dell\'applicazione...';
if (phaseEl) phaseEl.textContent = 'Fase: Download';
}
const versionEl = document.getElementById('pwa-boot-version');
fetch('/version.json?cb=' + Date.now())
.then(res => {
if (res.ok) return res.json();
throw new Error('Fallback');
})
.then(data => {
if (data && data.version && versionEl) {
versionEl.textContent = 'Versione ' + data.version;
}
})
.catch(e => {
if (versionEl) {
versionEl.textContent = '';
}
});
// 3. Start default progressive animation
let percent = 0;
const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent');
const interval = setInterval(() => {
// Only auto-advance if we are in the initial boot/install phase and not hijacked by Angular
if (percent < 90) {
percent += Math.floor(Math.random() * 8) + 3;
if (percent > 90) percent = 90;
if (bar) bar.style.width = percent + '%';
if (pctText) pctText.textContent = percent + '%';
} else {
clearInterval(interval);
}
}, 80);
})();
</script>
</body>
</html>
+22
View File
@@ -8,5 +8,27 @@ if (environment.production) {
enableProdMode();
}
// Patch window.fetch to automatically inject basic auth header for api.canticristiani.it
const originalFetch = window.fetch;
window.fetch = function(input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url);
if (url.includes('api.canticristiani.it')) {
init = init || {};
init.headers = init.headers || {};
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
if (init.headers instanceof Headers) {
init.headers.set('Authorization', authHeader);
} else if (Array.isArray(init.headers)) {
const hasAuth = init.headers.some(([key]) => key.toLowerCase() === 'authorization');
if (!hasAuth) {
init.headers.push(['Authorization', authHeader]);
}
} else {
(init.headers as Record<string, string>)['Authorization'] = authHeader;
}
}
return originalFetch(input, init);
};
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err));