Compare commits
10 Commits
adc3d510ad
...
97a11d5074
| Author | SHA1 | Date | |
|---|---|---|---|
| 97a11d5074 | |||
| 2d23dcae83 | |||
| 1b6e8a1832 | |||
| b4b7e10c5f | |||
| d023bb7d3f | |||
| 4d1883a45d | |||
| e7e43468b3 | |||
| 92e955915e | |||
| 960c73fbd0 | |||
| b220167794 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id_comunita": "123456",
|
||||
"nome_comunita": "Comunità Test 123456",
|
||||
"canti": [378, 379, 380]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+47
-72
@@ -8,18 +8,41 @@ 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 ---
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
REMOTE_DIR="" # Carica nella root della cartella FTP
|
||||
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
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
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=""
|
||||
|
||||
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 ---
|
||||
@@ -27,16 +50,20 @@ 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"
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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')}")
|
||||
@@ -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]}")
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
+406
-48
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
|
||||
.seg-text {
|
||||
white-space: pre-wrap;
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+758
-103
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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#');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> •
|
||||
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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const VERSION = '2026.05.22.0110';
|
||||
export const VERSION = '2026.06.17.0049';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
contactEmail: 'info@canticristiani.it',
|
||||
appName: 'CantiCristiani'
|
||||
appName: 'CantiCristiani',
|
||||
apiAuthUser: 'canti',
|
||||
apiAuthPass: 'antani2026'
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
contactEmail: 'info@canticristiani.it',
|
||||
appName: 'CantiCristiani'
|
||||
appName: 'CantiCristiani',
|
||||
apiAuthUser: 'canti',
|
||||
apiAuthPass: 'antani2026'
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
+126
-3
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user