Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97a11d5074 | |||
| 2d23dcae83 | |||
| 1b6e8a1832 | |||
| b4b7e10c5f | |||
| d023bb7d3f | |||
| 4d1883a45d | |||
| e7e43468b3 | |||
| 92e955915e | |||
| 960c73fbd0 | |||
| b220167794 |
@@ -1,4 +1,9 @@
|
|||||||
FTP_PASSWORD=cantiDavid@72
|
FTP_PASSWORD=cantiDavid@72
|
||||||
FTP_THREADS=30
|
FTP_THREADS=100
|
||||||
BASE_HREF=/ionic/
|
BASE_HREF=/ionic/
|
||||||
CONTACT_EMAIL=info@canticristiani.it
|
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_HOST="ftp.canticristiani.it"
|
||||||
FTP_USER="canticristiani.it"
|
FTP_USER="canticristiani.it"
|
||||||
FTP_PASS="$FTP_PASSWORD"
|
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"
|
REMOTE_PATH="api/cantiletture.json"
|
||||||
|
|
||||||
if [ -z "$FTP_PASS" ]; then
|
if [ -z "$FTP_PASS" ]; then
|
||||||
@@ -23,7 +23,7 @@ fi
|
|||||||
# --- Download del file ---
|
# --- Download del file ---
|
||||||
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
|
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
|
||||||
TEMP_FILE=$(mktemp)
|
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."
|
echo "❌ Errore nel download dei dati."
|
||||||
rm "$TEMP_FILE"
|
rm "$TEMP_FILE"
|
||||||
exit 1
|
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 "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||||
echo "🏷️ Versione aggiornata a: $VERSION"
|
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
|
# 2. Build dell'applicazione
|
||||||
echo "📦 Compilazione in corso (Production Build)..."
|
echo "📦 Compilazione in corso (Production Build)..."
|
||||||
@@ -37,6 +59,11 @@ fi
|
|||||||
|
|
||||||
echo "✅ Build completata con successo in: $DIST_PATH"
|
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
|
# 2.5 In locale usiamo la root
|
||||||
echo "📁 Build pronta nella root..."
|
echo "📁 Build pronta nella root..."
|
||||||
# Nessuna sottocartella ionic necessaria in locale
|
# Nessuna sottocartella ionic necessaria in locale
|
||||||
|
|||||||
+49
-74
@@ -8,18 +8,41 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Configurazione Parallelismo ---
|
# --- Configurazione Target e Parallelismo ---
|
||||||
THREADS=${1:-${FTP_THREADS:-30}}
|
TARGET="tophost"
|
||||||
|
THREADS=""
|
||||||
|
|
||||||
# --- Configurazione FTP per ROOT www.canticristiani.it ---
|
if [ "$1" = "contabo" ] || [ "$1" = "tophost" ]; then
|
||||||
FTP_HOST="ftp.canticristiani.it"
|
TARGET="$1"
|
||||||
FTP_USER="canticristiani.it"
|
THREADS="$2"
|
||||||
FTP_PASS="$FTP_PASSWORD"
|
else
|
||||||
REMOTE_DIR="" # Carica nella root della cartella FTP
|
# 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}}
|
||||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
export FTP_THREADS=$THREADS
|
||||||
exit 1
|
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
# --- Aggiornamento Versione ---
|
# --- Aggiornamento Versione ---
|
||||||
@@ -27,16 +50,20 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
|
|||||||
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||||
echo "🏷️ Versione aggiornata a: $VERSION"
|
echo "🏷️ Versione aggiornata a: $VERSION"
|
||||||
|
|
||||||
# --- Configurazione Email Parametrica ---
|
# --- Configurazione Email e API Parametriche ---
|
||||||
node -e "
|
node -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
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 => {
|
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
|
||||||
if (fs.existsSync(file)) {
|
if (fs.existsSync(file)) {
|
||||||
let content = fs.readFileSync(file, 'utf8');
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
|
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');
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Upload via FTP ---
|
# --- Genera version.json per il polling PWA ---
|
||||||
echo "🚀 2/2 Caricamento parallelo ($THREADS connessioni) su $FTP_HOST nella ROOT..."
|
BUILD_TIMESTAMP=$(date +%s)000
|
||||||
cd www
|
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)
|
# --- Upload ---
|
||||||
PROGRESS_LOG=$(mktemp)
|
if [ "$TARGET" = "tophost" ]; then
|
||||||
ERROR_LOG=$(mktemp)
|
echo "🚀 Upload via FTP su Tophost in corso..."
|
||||||
|
python3 scratch/deploy_ftp.py
|
||||||
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"
|
|
||||||
else
|
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
|
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)
|
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
|
||||||
RewriteRule ^ index.html [L]
|
RewriteRule ^ index.html [L]
|
||||||
</IfModule>
|
</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": "/",
|
"start_url": "/",
|
||||||
"theme_color": "#3880ff",
|
"theme_color": "#3880ff",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
|
"protocol_handlers": [
|
||||||
|
{
|
||||||
|
"protocol": "web+canti",
|
||||||
|
"url": "/?url=%s"
|
||||||
|
}
|
||||||
|
],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "assets/icons/icon-72x72.png",
|
"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-app>
|
||||||
<ion-router-outlet></ion-router-outlet>
|
<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>
|
<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>
|
</ion-app>
|
||||||
|
|||||||
+408
-50
@@ -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 { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||||
import { filter, first } from 'rxjs/operators';
|
import { filter, first } from 'rxjs/operators';
|
||||||
import { concat, interval, fromEvent } from 'rxjs';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -9,61 +14,414 @@ import { concat, interval, fromEvent } from 'rxjs';
|
|||||||
styleUrls: ['app.component.scss'],
|
styleUrls: ['app.component.scss'],
|
||||||
standalone: false,
|
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 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() {
|
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() {
|
||||||
if (this.swUpdate.isEnabled) {
|
const ready = this.settingsService.isVersionCheckComplete() && this.cantiService.firstLoadCompleted();
|
||||||
// Wait for the application to stabilize before running update checks or starting intervals
|
if (!ready) {
|
||||||
this.appRef.isStable.pipe(
|
return;
|
||||||
filter(stable => stable),
|
}
|
||||||
first()
|
|
||||||
).subscribe(() => {
|
|
||||||
console.log('[PWA-Update] App is stable. Initializing update checks...');
|
|
||||||
|
|
||||||
// 1. Check for updates immediately
|
// Se l'overlay di redirect o di installazione è mostrato, NON nascondiamo il loader iniziale
|
||||||
this.swUpdate.checkForUpdate().catch(err => {
|
// per rimanere nella welcome page mentre l'utente sceglie.
|
||||||
console.warn('[PWA-Update] Failed immediate startup update check:', err);
|
if (this.showRedirectOverlay() || this.showInstallOverlay()) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Periodic check in background every 30 seconds
|
// Altrimenti, nascondiamo il loader per far entrare l'utente nell'app
|
||||||
const every30Seconds$ = interval(30 * 1000);
|
if ((window as any).PwaLoader) {
|
||||||
every30Seconds$.subscribe(async () => {
|
(window as any).PwaLoader.hide();
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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] 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 { NgModule, isDevMode } from '@angular/core';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { RouteReuseStrategy } from '@angular/router';
|
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 { IonicStorageModule } from '@ionic/storage-angular';
|
||||||
|
|
||||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||||
@@ -9,6 +9,7 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
|||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||||
|
import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AppComponent],
|
declarations: [AppComponent],
|
||||||
@@ -24,7 +25,10 @@ import { ServiceWorkerModule } from '@angular/service-worker';
|
|||||||
registrationStrategy: 'registerImmediately'
|
registrationStrategy: 'registerImmediately'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
|
providers: [
|
||||||
|
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true }
|
||||||
|
],
|
||||||
bootstrap: [AppComponent],
|
bootstrap: [AppComponent],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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">
|
<div class="scanner-container">
|
||||||
<zxing-scanner
|
<zxing-scanner
|
||||||
[formats]="allowedFormats"
|
[formats]="allowedFormats"
|
||||||
|
[device]="currentDevice"
|
||||||
|
(camerasFound)="onCamerasFound($event)"
|
||||||
(scanSuccess)="onCodeResult($event)">
|
(scanSuccess)="onCodeResult($event)">
|
||||||
</zxing-scanner>
|
</zxing-scanner>
|
||||||
|
|
||||||
<div class="scan-overlay">
|
<div class="scan-overlay">
|
||||||
<div class="scan-frame"></div>
|
<div class="scan-frame"></div>
|
||||||
<p class="scan-text">Inquadra il QR Code della tua parrocchia</p>
|
<p class="scan-text">Inquadra il QR Code della tua parrocchia</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|||||||
@@ -55,6 +55,91 @@
|
|||||||
padding: 0 40px;
|
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 {
|
@keyframes scan {
|
||||||
|
|||||||
@@ -16,6 +16,39 @@ export class QrScannerComponent {
|
|||||||
private modalCtrl = inject(ModalController);
|
private modalCtrl = inject(ModalController);
|
||||||
public allowedFormats = [BarcodeFormat.QR_CODE];
|
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) {
|
onCodeResult(result: string) {
|
||||||
if (result) {
|
if (result) {
|
||||||
this.modalCtrl.dismiss(result);
|
this.modalCtrl.dismiss(result);
|
||||||
|
|||||||
+81
-94
@@ -5,7 +5,12 @@
|
|||||||
<img src="assets/icon/favicon.png" class="header-logo">
|
<img src="assets/icon/favicon.png" class="header-logo">
|
||||||
<div class="header-text-group">
|
<div class="header-text-group">
|
||||||
<span class="app-name">{{ appName }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</ion-title>
|
</ion-title>
|
||||||
@@ -14,9 +19,6 @@
|
|||||||
<ion-button (click)="importPlaylist()" class="add-btn">
|
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||||
</ion-button>
|
</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-button routerLink="/settings" class="settings-btn">
|
||||||
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -43,6 +45,18 @@
|
|||||||
{{ filteredCanti().length }}
|
{{ filteredCanti().length }}
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-buttons">
|
<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()">
|
<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
|
<ion-button
|
||||||
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
[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>
|
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
|
||||||
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
||||||
</ion-button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ion-button
|
<ion-button
|
||||||
*ngIf="settingsService.showEditor()"
|
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
|
||||||
[fill]="showOnlyMine() ? 'solid' : 'outline'"
|
|
||||||
size="small"
|
size="small"
|
||||||
(click)="toggleOnlyMine()"
|
(click)="toggleFilterType('lista_completa')"
|
||||||
color="secondary"
|
|
||||||
class="filter-chip">
|
class="filter-chip">
|
||||||
Miei
|
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
|
||||||
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
|
<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>
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
</span>
|
</span>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -82,7 +87,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
(click)="toggleFilterType('liturgico')"
|
(click)="toggleFilterType('liturgico')"
|
||||||
class="filter-chip">
|
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>
|
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
|
||||||
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
|
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
@@ -94,7 +99,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
(click)="toggleFilterType('tematico')"
|
(click)="toggleFilterType('tematico')"
|
||||||
class="filter-chip">
|
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>
|
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
|
||||||
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
|
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
@@ -112,19 +117,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</ion-button>
|
</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
|
<ion-button
|
||||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -142,45 +134,77 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- Category Scroll (Dropdown style) -->
|
<!-- Category Scroll (Dropdown style) -->
|
||||||
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="activeFilterType() || playlistService.selectionMode() || (playlistService.activeListName() && !playlistService.selectionMode())">
|
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="shouldShowSubSectionToolbar()">
|
||||||
<div class="momento-scroll">
|
<div class="momento-scroll" *ngIf="activeFilterType() !== 'playlist' || playlistService.allPlaylists().length > 0 || playlistService.selectionMode()">
|
||||||
<!-- Selection Mode Actions -->
|
<!-- Selection Mode Actions -->
|
||||||
<div class="selection-pill glass" *ngIf="playlistService.selectionMode()">
|
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
|
||||||
<span class="selection-count">{{ playlistService.selectedIds().size }}</span>
|
<span class="selection-count">{{ reorderList().length }}</span>
|
||||||
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn">
|
<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-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
||||||
</ion-button>
|
</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-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||||
</ion-button>
|
</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-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Playlist Actions -->
|
<!-- Active Playlist Actions -->
|
||||||
<div class="selection-pill glass" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
|
<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()">
|
<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-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
||||||
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
|
<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>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter chips -->
|
<!-- Filter chips -->
|
||||||
<div
|
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
|
||||||
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
|
<div
|
||||||
class="momento-chip glass"
|
class="momento-chip glass"
|
||||||
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
|
[class.active]="showValidati()"
|
||||||
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
|
(click)="toggleValidati()">
|
||||||
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
|
Validati
|
||||||
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
</div>
|
||||||
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
|
<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"
|
||||||
|
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
|
||||||
|
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
|
||||||
|
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
|
||||||
|
<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>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
@@ -192,28 +216,6 @@
|
|||||||
(click)="onInteraction()">
|
(click)="onInteraction()">
|
||||||
|
|
||||||
<div class="ion-padding no-padding-top">
|
<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 *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
|
||||||
<div class="loading-wrapper">
|
<div class="loading-wrapper">
|
||||||
@@ -281,9 +283,9 @@
|
|||||||
|
|
||||||
<div class="item-wrapper">
|
<div class="item-wrapper">
|
||||||
<!-- Selection Area -->
|
<!-- 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)">
|
<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>
|
</span>
|
||||||
</div>
|
</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;">
|
<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 *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>{{ 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>
|
</h2>
|
||||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
<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>
|
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
||||||
@@ -418,20 +421,4 @@
|
|||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-footer>
|
</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;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
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 {
|
.header-logo {
|
||||||
@@ -318,7 +318,7 @@ ion-title {
|
|||||||
.search-wrapper-group {
|
.search-wrapper-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 16px 8px 16px;
|
padding: 6px 16px 8px 16px; // Added slight top padding for search bar breathing room
|
||||||
gap: 8px;
|
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 Layout
|
||||||
.custom-item {
|
.custom-item {
|
||||||
--padding-start: 16px;
|
--padding-start: 16px;
|
||||||
@@ -946,12 +964,24 @@ ion-title {
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
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 {
|
:host-context(body.high-contrast) .non-validato-badge {
|
||||||
background: rgba(231, 76, 60, 0.1) !important;
|
background: rgba(231, 76, 60, 0.1) !important;
|
||||||
color: #c0392b !important;
|
color: #c0392b !important;
|
||||||
border-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+875
-163
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 -->
|
<!-- Previous line -->
|
||||||
<div *ngIf="displayLines().prev" class="prev-line">
|
<div *ngIf="displayLines().prev" class="prev-line">
|
||||||
<ng-container *ngIf="showChords(); else prevText">
|
<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 *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
<span class="seg-text">{{ seg.text }}</span>
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<!-- Active line -->
|
<!-- Active line -->
|
||||||
<div *ngIf="displayLines().current" class="active-line outfit-font">
|
<div *ngIf="displayLines().current" class="active-line outfit-font">
|
||||||
<ng-container *ngIf="showChords(); else currentText">
|
<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 *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
<span class="seg-text">{{ seg.text }}</span>
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<!-- Next line -->
|
<!-- Next line -->
|
||||||
<div *ngIf="displayLines().next" class="next-line">
|
<div *ngIf="displayLines().next" class="next-line">
|
||||||
<ng-container *ngIf="showChords(); else nextText">
|
<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 *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
<span class="seg-text">{{ seg.text }}</span>
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -74,6 +74,9 @@
|
|||||||
|
|
||||||
.seg-text {
|
.seg-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
&::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-info {
|
.footer-info {
|
||||||
|
|||||||
@@ -16,14 +16,42 @@ export class DisplayPage implements OnInit, OnDestroy {
|
|||||||
public showChords = signal<boolean>(false);
|
public showChords = signal<boolean>(false);
|
||||||
public fontSize = signal<number>(1.0);
|
public fontSize = signal<number>(1.0);
|
||||||
public currentLineIndex = signal<number>(0);
|
public currentLineIndex = signal<number>(0);
|
||||||
|
public transposeAmount = signal<number>(0);
|
||||||
|
|
||||||
public parsedSections = computed<ParsedSection[]>(() => {
|
public parsedSections = computed<ParsedSection[]>(() => {
|
||||||
const c = this.canto();
|
const c = this.canto();
|
||||||
if (!c) return [];
|
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 */
|
/** 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 route = inject(ActivatedRoute);
|
||||||
private cantiService = inject(CantiService);
|
private cantiService = inject(CantiService);
|
||||||
private lyricsParser = inject(LyricsParserService);
|
public lyricsParser = inject(LyricsParserService);
|
||||||
private comunitaService = inject(ComunitaService);
|
private comunitaService = inject(ComunitaService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -94,6 +122,7 @@ export class DisplayPage implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
if (event.data.type === 'SYNC_CANTO') {
|
if (event.data.type === 'SYNC_CANTO') {
|
||||||
this.activeSongId.set(event.data.id);
|
this.activeSongId.set(event.data.id);
|
||||||
|
this.transposeAmount.set(0);
|
||||||
}
|
}
|
||||||
if (event.data.type === 'SYNC_CHORDS') {
|
if (event.data.type === 'SYNC_CHORDS') {
|
||||||
this.showChords.set(event.data.showChords);
|
this.showChords.set(event.data.showChords);
|
||||||
@@ -101,6 +130,9 @@ export class DisplayPage implements OnInit, OnDestroy {
|
|||||||
if (event.data.type === 'SYNC_FONT') {
|
if (event.data.type === 'SYNC_FONT') {
|
||||||
this.fontSize.set(event.data.fontSize);
|
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-header [translucent]="true" class="ion-no-border">
|
||||||
<ion-toolbar class="bg-gradient top-toolbar">
|
<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">
|
<ion-buttons slot="end">
|
||||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||||
</div>
|
</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"
|
<ion-icon slot="icon-only"
|
||||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||||
[color]="showChords() ? 'secondary' : 'medium'"
|
[color]="showChords() ? 'secondary' : 'medium'"
|
||||||
@@ -12,21 +32,6 @@
|
|||||||
</ion-icon>
|
</ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</ion-buttons>
|
</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>
|
</ion-toolbar>
|
||||||
|
|
||||||
<!-- Audio Toolbar Removed -->
|
<!-- Audio Toolbar Removed -->
|
||||||
@@ -36,58 +41,66 @@
|
|||||||
<div class="lyrics-container"
|
<div class="lyrics-container"
|
||||||
[style.fontSize.rem]="fontSize()"
|
[style.fontSize.rem]="fontSize()"
|
||||||
[class.full-screen-container]="settingsService.fullscreenMode()"
|
[class.full-screen-container]="settingsService.fullscreenMode()"
|
||||||
|
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
|
||||||
(touchstart)="onTouchStart($event)"
|
(touchstart)="onTouchStart($event)"
|
||||||
(touchmove)="onTouchMove($event)"
|
(touchmove)="onTouchMove($event)"
|
||||||
(touchend)="onTouchEnd()">
|
(touchend)="onTouchEnd()">
|
||||||
|
|
||||||
|
|
||||||
<!-- Landscape Side Controls (Scrollable) -->
|
<!-- Landscape Side Controls (Scrollable) -->
|
||||||
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
|
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
|
||||||
<div class="side-scroll-container">
|
<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;">
|
<!-- Navigation Group -->
|
||||||
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10" style="height: 32px; margin: 0;">
|
<div class="side-group">
|
||||||
<ion-icon name="add" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
|
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
|
||||||
</ion-button>
|
prv
|
||||||
<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>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="side-group">
|
||||||
<div class="side-group equidistant-group">
|
|
||||||
<!-- Navigation -->
|
|
||||||
<ion-button fill="clear" (click)="restart()">
|
|
||||||
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
|
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
|
||||||
<ion-icon name="chevron-up" color="secondary"></ion-icon>
|
<ion-icon slot="icon-only" 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-button>
|
</ion-button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,8 +120,8 @@
|
|||||||
[class.active]="isActiveLine(si, li)">
|
[class.active]="isActiveLine(si, li)">
|
||||||
|
|
||||||
<!-- Chord mode: show chords above text -->
|
<!-- Chord mode: show chords above text -->
|
||||||
<ng-container *ngIf="showChords(); else textOnly">
|
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
|
||||||
<span *ngFor="let seg of line.segments" class="chord-segment">
|
<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 *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
<span class="seg-text">{{ seg.text }}</span>
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -120,49 +133,49 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
||||||
<ion-footer class="ion-no-border">
|
<ion-footer class="ion-no-border">
|
||||||
<!-- Audio Player Toolbar -->
|
<!-- Audio Player / Playlist Navigation Toolbar -->
|
||||||
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
|
<ion-toolbar class="global-player-toolbar glass">
|
||||||
<div class="player-content">
|
<div class="player-content">
|
||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
|
<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>
|
</ion-button>
|
||||||
|
|
||||||
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
|
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
|
||||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
|
||||||
</ion-button>
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
<ion-range
|
<ion-range
|
||||||
[min]="0"
|
[min]="0"
|
||||||
[max]="youtubePlayerService.videoDuration()"
|
[max]="youtubePlayerService.videoDuration()"
|
||||||
[value]="youtubePlayerService.videoProgress()"
|
[value]="youtubePlayerService.videoProgress()"
|
||||||
(ionChange)="onSeek($event)"
|
(ionChange)="onSeek($event)"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
class="global-range">
|
class="global-range">
|
||||||
</ion-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-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
|
||||||
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
<ion-icon slot="icon-only" name="chevron-forward"></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-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</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">
|
<ion-toolbar class="bg-gradient slim-toolbar">
|
||||||
<div class="slim-controls">
|
<div class="slim-controls">
|
||||||
<!-- Autoscroll Standard in Portrait Footer -->
|
<!-- Autoscroll Standard in Portrait Footer -->
|
||||||
@@ -179,24 +192,16 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Karaoke Toggle in Portrait -->
|
<!-- Camera Head Gestures Toggle in Portrait -->
|
||||||
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" size="small" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'" class="mic-btn-portrait">
|
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
|
||||||
<ion-icon slot="icon-only" [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
|
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
|
||||||
</ion-button>
|
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
|
||||||
|
|
||||||
|
|
||||||
<!-- 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>
|
</ion-button>
|
||||||
<div class="transpose-indicator">
|
<div class="camera-indicator" *ngIf="enableCameraNavigation()" [class.tilted]="faceDetector.isTilted()">
|
||||||
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
<ion-icon name="person-outline"
|
||||||
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
|
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
|
||||||
|
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
|
||||||
</div>
|
</div>
|
||||||
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
|
||||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
@@ -212,41 +217,48 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</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 -->
|
<!-- Zoom -->
|
||||||
<div class="group">
|
<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-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
|
||||||
</ion-button>
|
</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-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- YouTube Link in Portrait Footer -->
|
||||||
<div class="group" *ngIf="connectivityService.isOnline()">
|
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
|
||||||
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()">
|
<ion-button fill="clear" size="small" (click)="openYoutube()">
|
||||||
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
|
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-footer>
|
</ion-footer>
|
||||||
|
|
||||||
<!-- Vertical Sensitivity Slider Overlay -->
|
<!-- Black Screen Overlay -->
|
||||||
<div class="mic-sensitivity-overlay" *ngIf="settingsService.enableAcousticAutoscroll() && showSensitivitySlider() && audioEngine.isListening()">
|
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()"></div>
|
||||||
<div class="slider-card glass">
|
|
||||||
<ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn">
|
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
|
||||||
<ion-icon name="close-outline"></ion-icon>
|
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
|
|
||||||
// Force left alignment in Ionic toolbar
|
// Force left alignment in Ionic toolbar
|
||||||
ion-title {
|
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;
|
text-align: left !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +172,9 @@
|
|||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-height: 1.5em;
|
min-height: 1.5em;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
@@ -186,6 +190,10 @@
|
|||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
margin-right: 0.2em;
|
margin-right: 0.2em;
|
||||||
|
|
||||||
|
&.contiguous-next {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.chord {
|
.chord {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -196,6 +204,9 @@
|
|||||||
|
|
||||||
.seg-text {
|
.seg-text {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
&::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,13 +264,44 @@
|
|||||||
|
|
||||||
ion-icon { font-size: 0.9rem; }
|
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
|
||||||
.transcript-area {
|
.transcript-area {
|
||||||
height: 4px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
|
|
||||||
.energy-bar {
|
.energy-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -269,8 +311,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fullscreen and Orientation
|
@keyframes pulse {
|
||||||
@media (orientation: landscape) {
|
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
|
// Always hide footer in landscape as we have side controls
|
||||||
ion-footer {
|
ion-footer {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -279,33 +326,30 @@
|
|||||||
.lyrics-container {
|
.lyrics-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-top: 4px; // Minimized
|
padding-top: 4px; // Minimized
|
||||||
|
padding-bottom: 8px !important;
|
||||||
padding-right: 90px !important; // More room for side controls and zoom
|
padding-right: 90px !important; // More room for side controls and zoom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lyrics-view {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
ion-content {
|
ion-content {
|
||||||
--offset-bottom: 0px !important;
|
--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 {
|
.landscape-side-controls {
|
||||||
display: none !important; // Strict hidden in portrait
|
display: none !important; // Strict hidden in portrait
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
:host(.landscape-active) & {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 44px !important; // Align with header
|
top: 56px !important; // Align below header
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 60px;
|
width: 50px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -317,8 +361,10 @@ ion-content.full-screen-content {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px 0;
|
justify-content: flex-start;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
gap: 12px;
|
||||||
&::-webkit-scrollbar { display: none; }
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,23 +372,82 @@ ion-content.full-screen-content {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px; // Reduced for a more compact layout
|
gap: 8px; // Reduced for a more compact layout
|
||||||
padding-bottom: 20px;
|
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 {
|
ion-button {
|
||||||
--padding-start: 0;
|
--padding-start: 0;
|
||||||
--padding-end: 0;
|
--padding-end: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 48px;
|
height: 38px;
|
||||||
ion-icon { font-size: 1.8rem; }
|
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 {
|
.side-divider {
|
||||||
display: none;
|
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 {
|
.mic-sensitivity-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 15px;
|
right: 15px;
|
||||||
@@ -351,7 +456,7 @@ ion-content.full-screen-content {
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
:host(.landscape-active) & {
|
||||||
right: 80px; // Prossimo ai controlli laterali
|
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 {
|
.floating-autoscroll-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 120px;
|
bottom: 120px;
|
||||||
@@ -522,6 +730,27 @@ ion-content.full-screen-content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:host-context(body.high-contrast) {
|
: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 {
|
.slim-controls .group {
|
||||||
background: rgba(0, 0, 0, 0.05) !important;
|
background: rgba(0, 0, 0, 0.05) !important;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||||
@@ -553,3 +782,137 @@ ion-content.full-screen-content {
|
|||||||
color: #c0392b !important;
|
color: #c0392b !important;
|
||||||
border-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+761
-106
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
|||||||
<ion-icon name="reorder-two-outline"></ion-icon>
|
<ion-icon name="reorder-two-outline"></ion-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-info">
|
<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 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 *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export class PlaylistPage {
|
|||||||
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex);
|
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() {
|
async savePlaylist() {
|
||||||
const alert = await this.alertCtrl.create({
|
const alert = await this.alertCtrl.create({
|
||||||
header: 'Salva Playlist',
|
header: 'Salva Playlist',
|
||||||
@@ -81,7 +90,7 @@ export class PlaylistPage {
|
|||||||
handler: (data) => {
|
handler: (data) => {
|
||||||
if (data.name) {
|
if (data.name) {
|
||||||
this.savedPlaylistName = 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.showToast('Playlist salvata!');
|
||||||
this.router.navigate(['/settings']);
|
this.router.navigate(['/settings']);
|
||||||
return true;
|
return true;
|
||||||
@@ -117,8 +126,9 @@ export class PlaylistPage {
|
|||||||
if (data.name) {
|
if (data.name) {
|
||||||
this.savedPlaylistName = data.name;
|
this.savedPlaylistName = data.name;
|
||||||
const ids = this.localSongs.map(s => s.id);
|
const ids = this.localSongs.map(s => s.id);
|
||||||
await this.playlistService.savePlaylist(data.name, ids);
|
const songSettings = this.getPlaylistSongSettings();
|
||||||
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name);
|
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
||||||
|
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name, songSettings);
|
||||||
this.showToast('Playlist salvata!');
|
this.showToast('Playlist salvata!');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -135,7 +145,8 @@ export class PlaylistPage {
|
|||||||
|
|
||||||
const ids = this.localSongs.map(s => s.id);
|
const ids = this.localSongs.map(s => s.id);
|
||||||
const name = this.savedPlaylistName || 'Playlist Condivisa';
|
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() {
|
downloadQR() {
|
||||||
@@ -157,7 +168,8 @@ export class PlaylistPage {
|
|||||||
async shareQR() {
|
async shareQR() {
|
||||||
if (!this.qrCodeImage) return;
|
if (!this.qrCodeImage) return;
|
||||||
const name = this.savedPlaylistName || 'playlist';
|
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`;
|
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -180,6 +192,13 @@ export class PlaylistPage {
|
|||||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
|
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
|
||||||
});
|
});
|
||||||
} else {
|
} 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();
|
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));
|
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;
|
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-toolbar>
|
||||||
</ion-header>
|
</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">
|
<div class="propose-container">
|
||||||
<!-- OCR Progress -->
|
<!-- OCR Progress -->
|
||||||
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
|
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
|
||||||
@@ -18,106 +31,159 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ion-list lines="none" class="input-list">
|
<div class="editor-preview-split">
|
||||||
<ion-item class="custom-input-item">
|
<!-- Left Column: Inputs & Editor -->
|
||||||
<ion-label position="stacked">Titolo del Canto</ion-label>
|
<div class="editor-column">
|
||||||
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
<ion-list lines="none" class="input-list">
|
||||||
</ion-item>
|
<ion-item class="custom-input-item">
|
||||||
|
<ion-label position="stacked">Titolo del Canto</ion-label>
|
||||||
|
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
<div class="category-selectors">
|
<div class="category-selectors">
|
||||||
<ion-item class="custom-input-item select-item">
|
<ion-item class="custom-input-item select-item">
|
||||||
<ion-label position="stacked">Momento Liturgico</ion-label>
|
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||||
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
||||||
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
|
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
|
||||||
{{ lit.tag_name }}
|
{{ lit.tag_name }}
|
||||||
</ion-select-option>
|
</ion-select-option>
|
||||||
</ion-select>
|
</ion-select>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item class="custom-input-item select-item">
|
<ion-item class="custom-input-item select-item">
|
||||||
<ion-label position="stacked">Periodo / Tema</ion-label>
|
<ion-label position="stacked">Periodo / Tema</ion-label>
|
||||||
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
||||||
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
|
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
|
||||||
{{ tem.tag_name }}
|
{{ tem.tag_name }}
|
||||||
</ion-select-option>
|
</ion-select-option>
|
||||||
</ion-select>
|
</ion-select>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TOOLBARS -->
|
|
||||||
<div class="toolbar-section">
|
|
||||||
<div class="horizontal-toolbar">
|
|
||||||
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
|
|
||||||
{{ tag.label }}
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Chords Selector Toolbar -->
|
|
||||||
<div class="toolbar-section">
|
|
||||||
<div class="horizontal-toolbar main-chords-toolbar">
|
|
||||||
<ion-button
|
|
||||||
*ngFor="let group of groupedChords"
|
|
||||||
size="small"
|
|
||||||
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
|
|
||||||
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
|
|
||||||
(click)="selectRoot(group.root)">
|
|
||||||
{{ group.root }}
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Variations Toolbar (only visible if a root chord is selected) -->
|
|
||||||
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
|
|
||||||
<div class="horizontal-toolbar variations-toolbar">
|
|
||||||
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
|
|
||||||
<ion-button
|
|
||||||
size="small"
|
|
||||||
*ngFor="let chord of getVariations()"
|
|
||||||
(click)="insertChord(chord)">
|
|
||||||
{{ chord }}
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- EDITOR AREA -->
|
|
||||||
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
|
||||||
<div class="editor-header">
|
|
||||||
<div class="editor-title-group">
|
|
||||||
<span>Editor Testo</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-actions">
|
|
||||||
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
|
<!-- TOOLBARS -->
|
||||||
<ion-icon name="undo-outline"></ion-icon>
|
<div class="toolbar-section">
|
||||||
</ion-button>
|
<div class="horizontal-toolbar">
|
||||||
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
|
||||||
<ion-icon name="camera-outline"></ion-icon>
|
{{ tag.label }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Chords Selector Toolbar -->
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<div class="horizontal-toolbar main-chords-toolbar">
|
||||||
|
<ion-button
|
||||||
|
*ngFor="let group of groupedChords"
|
||||||
|
size="small"
|
||||||
|
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
|
||||||
|
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
|
||||||
|
(click)="selectRoot(group.root)">
|
||||||
|
{{ group.root }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variations Toolbar (only visible if a root chord is selected) -->
|
||||||
|
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
|
||||||
|
<div class="horizontal-toolbar variations-toolbar">
|
||||||
|
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
|
||||||
|
<ion-button
|
||||||
|
size="small"
|
||||||
|
*ngFor="let chord of getVariations()"
|
||||||
|
(click)="insertChord(chord)">
|
||||||
|
{{ chord }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EDITOR AREA -->
|
||||||
|
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title-group">
|
||||||
|
<span>Editor Testo</span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
|
||||||
|
<ion-icon name="undo-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
||||||
|
<ion-icon name="camera-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ion-item class="custom-input-item textarea-item">
|
||||||
|
<ion-textarea
|
||||||
|
#contentTextarea
|
||||||
|
[(ngModel)]="content"
|
||||||
|
placeholder="Scrivi o scansiona..."
|
||||||
|
rows="18"
|
||||||
|
class="content-textarea"
|
||||||
|
(paste)="onPaste($event)">
|
||||||
|
</ion-textarea>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-item class="custom-input-item">
|
||||||
|
<ion-label position="stacked">Autore / Link YouTube</ion-label>
|
||||||
|
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
|
||||||
|
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
|
||||||
|
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||||
|
Salva nei Miei Canti
|
||||||
|
</ion-button>
|
||||||
|
</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>
|
</ion-button>
|
||||||
</div>
|
</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>
|
||||||
<ion-item class="custom-input-item textarea-item">
|
|
||||||
<ion-textarea
|
|
||||||
#contentTextarea
|
|
||||||
[(ngModel)]="content"
|
|
||||||
placeholder="Scrivi o scansiona..."
|
|
||||||
rows="18"
|
|
||||||
class="content-textarea">
|
|
||||||
</ion-textarea>
|
|
||||||
</ion-item>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ion-item class="custom-input-item">
|
|
||||||
<ion-label position="stacked">Autore / Link YouTube</ion-label>
|
|
||||||
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
|
|
||||||
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
|
|
||||||
</ion-item>
|
|
||||||
</ion-list>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
|
|
||||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
|
||||||
Salva nei Miei Canti
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ body.high-contrast :host ::ng-deep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.propose-container {
|
.propose-container {
|
||||||
max-width: 800px;
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
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 { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ProposeCantoPage } from './propose-canto.page';
|
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', () => {
|
describe('ProposeCantoPage', () => {
|
||||||
let component: ProposeCantoPage;
|
let component: ProposeCantoPage;
|
||||||
let fixture: ComponentFixture<ProposeCantoPage>;
|
let fixture: ComponentFixture<ProposeCantoPage>;
|
||||||
|
|
||||||
beforeEach(() => {
|
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);
|
fixture = TestBed.createComponent(ProposeCantoPage);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
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 { createWorker } from 'tesseract.js';
|
||||||
import { CantiService } from '../../services/canti.service';
|
import { CantiService } from '../../services/canti.service';
|
||||||
import { MyCantiService } from '../../services/my-canti.service';
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
|
import { PlaylistService } from '../../services/playlist.service';
|
||||||
import { ThemeService } from '../../services/theme.service';
|
import { ThemeService } from '../../services/theme.service';
|
||||||
|
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
|
||||||
|
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-propose-canto',
|
selector: 'app-propose-canto',
|
||||||
templateUrl: './propose-canto.page.html',
|
templateUrl: './propose-canto.page.html',
|
||||||
styleUrls: ['./propose-canto.page.scss'],
|
styleUrls: ['./propose-canto.page.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, IonicModule]
|
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
|
||||||
})
|
})
|
||||||
export class ProposeCantoPage implements OnInit {
|
export class ProposeCantoPage implements OnInit {
|
||||||
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
|
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
|
||||||
@@ -20,14 +23,29 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
|
|
||||||
public cantiService = inject(CantiService);
|
public cantiService = inject(CantiService);
|
||||||
private myCantiService = inject(MyCantiService);
|
private myCantiService = inject(MyCantiService);
|
||||||
|
private playlistService = inject(PlaylistService);
|
||||||
private navCtrl = inject(NavController);
|
private navCtrl = inject(NavController);
|
||||||
public themeService = inject(ThemeService);
|
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 = '';
|
title: string = '';
|
||||||
author: string = '';
|
author: string = '';
|
||||||
youtubeLink: string = '';
|
youtubeLink: string = '';
|
||||||
selectedLiturgico: number[] = [];
|
selectedLiturgico: number[] = [];
|
||||||
selectedTematico: number[] = [];
|
selectedTematico: number[] = [];
|
||||||
|
editId: string | null = null;
|
||||||
|
|
||||||
private _content: string = '';
|
private _content: string = '';
|
||||||
get content(): string { return this._content; }
|
get content(): string { return this._content; }
|
||||||
@@ -41,6 +59,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
undoStack: string[] = [];
|
undoStack: string[] = [];
|
||||||
isProcessingOCR: boolean = false;
|
isProcessingOCR: boolean = false;
|
||||||
ocrProgress: number = 0;
|
ocrProgress: number = 0;
|
||||||
|
isDraggingOver: boolean = false;
|
||||||
get isHighContrast(): boolean { return this.themeService.highContrast(); }
|
get isHighContrast(): boolean { return this.themeService.highContrast(); }
|
||||||
|
|
||||||
groupedChords = [
|
groupedChords = [
|
||||||
@@ -104,6 +123,33 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
|
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
|
||||||
|
|
||||||
ngOnInit() {
|
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) {
|
async insertText(tag: string) {
|
||||||
@@ -143,13 +189,70 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
this.cameraInput.nativeElement.click();
|
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) {
|
async onFileSelected(event: any, isCamera: boolean) {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
console.log('[OCR-Capture] Nessun file selezionato.');
|
console.log('[OCR-Capture] Nessun file selezionato.');
|
||||||
return;
|
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`);
|
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
|
||||||
|
|
||||||
this.isProcessingOCR = true;
|
this.isProcessingOCR = true;
|
||||||
@@ -187,7 +290,6 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
} finally {
|
} finally {
|
||||||
this.isProcessingOCR = false;
|
this.isProcessingOCR = false;
|
||||||
this.ocrProgress = 0;
|
this.ocrProgress = 0;
|
||||||
event.target.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,12 +381,95 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
return this.parseSongSpatially(words);
|
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 {
|
parseSongSpatially(words: any[]): string {
|
||||||
if (!words || words.length === 0) {
|
if (!words || words.length === 0) {
|
||||||
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
|
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
|
||||||
return '';
|
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}`);
|
console.log(`[OCR-Capture] Parole totali ricevute dall'OCR: ${words.length}`);
|
||||||
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
|
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
|
||||||
console.log(`[OCR-Capture] Parole valide dopo filtraggio: ${validWords.length}`);
|
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
|
// Calculate average word height to set vertical tolerance
|
||||||
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
|
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
|
||||||
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
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`);
|
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
|
||||||
|
|
||||||
// 2. Group words into horizontal lines
|
// 2. Group words into horizontal lines
|
||||||
@@ -324,16 +509,49 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Classify lines as Chords vs. Text
|
// 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 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);
|
return chordRegex.test(clean);
|
||||||
};
|
};
|
||||||
|
|
||||||
const classifiedLines = lines.map(line => {
|
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 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 {
|
return {
|
||||||
words: line,
|
words: line,
|
||||||
@@ -353,7 +571,8 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
|
|
||||||
if (current.isChords) {
|
if (current.isChords) {
|
||||||
const next = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
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!
|
// Merge spatially!
|
||||||
const merged = this.mergeChordsAndLyrics(current.words, next.words);
|
const merged = this.mergeChordsAndLyrics(current.words, next.words);
|
||||||
const lineText = next.words.map(w => w.text).join(' ');
|
const lineText = next.words.map(w => w.text).join(' ');
|
||||||
@@ -371,8 +590,37 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
i++; // Skip next line because we consumed it!
|
i++; // Skip next line because we consumed it!
|
||||||
} else {
|
} else {
|
||||||
// Chord line but no text below it: just wrap and print
|
// 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;
|
||||||
processedLines.push(wrapped);
|
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 {
|
} else {
|
||||||
const lineText = current.words.map(w => w.text).join(' ');
|
const lineText = current.words.map(w => w.text).join(' ');
|
||||||
@@ -386,7 +634,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
inVerse = true;
|
inVerse = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
processedLines.push(lineText);
|
processedLines.push(this.wrapChords(lineText, chordRegex));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we see a large vertical gap, close open blocks
|
// 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 {
|
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[]>();
|
const chordAssignments = new Map<any, any[]>();
|
||||||
|
|
||||||
chordWords.forEach(chord => {
|
expandedChordWords.forEach(chord => {
|
||||||
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
||||||
let closestWord: any = null;
|
let closestWord: any = null;
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
|
|
||||||
textWords.forEach(textWord => {
|
textWords.forEach(textWord => {
|
||||||
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
|
let dist = 0;
|
||||||
const dist = Math.abs(chordX - wordXCenter);
|
if (chordX < textWord.bbox.x0) {
|
||||||
|
dist = textWord.bbox.x0 - chordX;
|
||||||
|
} else if (chordX > textWord.bbox.x1) {
|
||||||
|
dist = chordX - textWord.bbox.x1;
|
||||||
|
}
|
||||||
|
|
||||||
if (dist < minDistance) {
|
if (dist < minDistance) {
|
||||||
minDistance = dist;
|
minDistance = dist;
|
||||||
closestWord = textWord;
|
closestWord = textWord;
|
||||||
@@ -433,22 +800,50 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
|
||||||
textWords.forEach((textWord, index) => {
|
textWords.forEach((textWord, index) => {
|
||||||
const assignedChords = chordAssignments.get(textWord) || [];
|
const assignedChords = chordAssignments.get(textWord) || [];
|
||||||
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
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 => {
|
assignedChords.forEach(chord => {
|
||||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
||||||
result += `[${cleanChord}]`;
|
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) {
|
if (index < textWords.length - 1) {
|
||||||
result += ' ';
|
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 {
|
private wrapChords(line: string, regex: RegExp): string {
|
||||||
const chordsInLine = line.match(regex);
|
const globalRegex = new RegExp(regex.source.replace(/^\^/, '').replace(/\$$/, ''), 'gi');
|
||||||
if (chordsInLine && chordsInLine.length > 0) {
|
const parts = line.split(/(_)/);
|
||||||
return line.replace(regex, (match) => `[${match.toUpperCase()}]`);
|
return parts.map((part: string) => {
|
||||||
}
|
if (part === '_') return ' _ ';
|
||||||
return line;
|
return part.replace(globalRegex, (match) => {
|
||||||
|
const lower = match.toLowerCase();
|
||||||
|
if (match === 'a' || match === 'e' || match === 'o' || match === 'i') {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
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,15 +911,66 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
// Combine lit and tematico for id_momenti
|
// Combine lit and tematico for id_momenti
|
||||||
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
|
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
|
||||||
|
|
||||||
await this.myCantiService.saveCanto({
|
const activePlaylistId = this.playlistService.activePlaylistId();
|
||||||
titolo: this.title,
|
const isRemotePlaylist = activePlaylistId && activePlaylistId.startsWith('remote_');
|
||||||
autore: this.author,
|
|
||||||
link_youtube: this.youtubeLink,
|
|
||||||
testo: this.content,
|
|
||||||
accordi: this.content, // Save to both fields for compatibility
|
|
||||||
id_momenti: id_momenti
|
|
||||||
});
|
|
||||||
|
|
||||||
this.navCtrl.back();
|
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,
|
||||||
|
testo: this.content,
|
||||||
|
accordi: this.content, // Save to both fields for compatibility
|
||||||
|
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-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
|
||||||
</ion-item>
|
</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);">
|
<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: rgba(255,255,255,0.8); margin: 0 0 12px 0; line-height: 1.4;">
|
<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>:
|
Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
|
||||||
</p>
|
</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;">
|
<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.
|
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>
|
</li>
|
||||||
@@ -62,14 +62,7 @@
|
|||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
|
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
|
||||||
</ion-item>
|
</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-item class="transparent-item" lines="none">
|
||||||
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
|
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
|
||||||
<ion-label class="outfit-font">
|
<ion-label class="outfit-font">
|
||||||
@@ -86,14 +79,7 @@
|
|||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-toggle slot="end" [checked]="settingsService.autoAdvance()" (ionChange)="settingsService.toggleAutoAdvance()" color="secondary"></ion-toggle>
|
<ion-toggle slot="end" [checked]="settingsService.autoAdvance()" (ionChange)="settingsService.toggleAutoAdvance()" color="secondary"></ion-toggle>
|
||||||
</ion-item>
|
</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-item class="transparent-item" lines="none">
|
||||||
<ion-icon name="create-outline" slot="start" color="secondary"></ion-icon>
|
<ion-icon name="create-outline" slot="start" color="secondary"></ion-icon>
|
||||||
<ion-label class="outfit-font">
|
<ion-label class="outfit-font">
|
||||||
@@ -127,12 +113,28 @@
|
|||||||
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
|
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="transparent-item" lines="none">
|
<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">
|
<ion-label class="outfit-font">
|
||||||
<h2 class="settings-item-title">Autoscroll acustico</h2>
|
<h2 class="settings-item-title">Autoscroll visuale</h2>
|
||||||
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p>
|
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
|
||||||
</ion-label>
|
</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>
|
</ion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,8 +146,109 @@
|
|||||||
<h2 class="settings-item-title">Comunità</h2>
|
<h2 class="settings-item-title">Comunità</h2>
|
||||||
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
|
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
|
||||||
</ion-label>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Invio Dati Statistici -->
|
<!-- Invio Dati Statistici -->
|
||||||
@@ -185,39 +288,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chord Notation Preference Section -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
|
<div class="group-header ion-padding-start ion-padding-top">
|
||||||
<div class="settings-group glass ion-margin-top" *ngIf="settingsService.showEditor()">
|
<h2 class="outfit-font settings-group-title">
|
||||||
<ion-item class="transparent-item" lines="none" (click)="myCantiService.sendAllMyCanti()" detail="true" button *ngIf="myCantiService.myCanti().length > 0">
|
Notazione Accordi Preferita
|
||||||
<ion-icon name="send-outline" slot="start" color="secondary"></ion-icon>
|
</h2>
|
||||||
<ion-label class="outfit-font">
|
</div>
|
||||||
<h2 class="settings-item-title">Proponi i miei canti ({{ myCantiService.myCanti().length }})</h2>
|
|
||||||
<p class="settings-item-subtitle">Invia a {{ contactEmail }}</p>
|
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
|
||||||
</ion-label>
|
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
|
||||||
</ion-item>
|
<div class="filter-btn glass"
|
||||||
</div>
|
[class.active-btn]="settingsService.chordNotationPreference() === 'diesis'"
|
||||||
|
(click)="settingsService.setChordNotationPreference('diesis')">
|
||||||
<div class="settings-group glass ion-margin-top">
|
<span>Diesis (#)</span>
|
||||||
<ion-item class="transparent-item" lines="none" (click)="fullRefresh()" detail="true" button>
|
</div>
|
||||||
<ion-icon name="cloud-download-outline" slot="start" color="secondary"></ion-icon>
|
<div class="filter-btn glass"
|
||||||
<ion-label class="outfit-font">
|
[class.active-btn]="settingsService.chordNotationPreference() === 'bemolle'"
|
||||||
<h2 class="settings-item-title">Allinea con Server</h2>
|
(click)="settingsService.setChordNotationPreference('bemolle')">
|
||||||
<p class="settings-item-subtitle">Aggiorna canti e versione app</p>
|
<span>Bemolle (b)</span>
|
||||||
</ion-label>
|
</div>
|
||||||
<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>
|
||||||
<div class="sync-progress" *ngIf="cantiService.loading()">
|
|
||||||
<div class="progress-bar" [style.width.%]="cantiService.progress()"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Legenda Pulsanti Canto -->
|
<!-- Legenda Pulsanti Canto -->
|
||||||
<div class="settings-group glass ion-margin-top">
|
<div class="settings-group glass ion-margin-top">
|
||||||
<div class="group-header ion-padding-start ion-padding-top">
|
<div class="group-header ion-padding-start ion-padding-top">
|
||||||
@@ -234,12 +333,12 @@
|
|||||||
<div class="legend-list">
|
<div class="legend-list">
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-icon-wrapper">
|
<div class="legend-icon-wrapper">
|
||||||
<ion-icon name="mic" color="danger"></ion-icon>
|
<ion-icon name="videocam" color="success"></ion-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-text">
|
<div class="legend-text">
|
||||||
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4>
|
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
|
||||||
<p class="outfit-font">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +379,7 @@
|
|||||||
<div class="legend-text">
|
<div class="legend-text">
|
||||||
<h4 class="outfit-font">Riavvia Canto</h4>
|
<h4 class="outfit-font">Riavvia Canto</h4>
|
||||||
<p class="outfit-font">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { ThemeService } from '../../services/theme.service';
|
|||||||
import { SettingsService } from '../../services/settings.service';
|
import { SettingsService } from '../../services/settings.service';
|
||||||
import { CantiService } from '../../services/canti.service';
|
import { CantiService } from '../../services/canti.service';
|
||||||
import { ConnectivityService } from '../../services/connectivity.service';
|
import { ConnectivityService } from '../../services/connectivity.service';
|
||||||
import { SwUpdate } from '@angular/service-worker';
|
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||||
import { ToastController, ModalController, AlertController } from '@ionic/angular';
|
import { ToastController, ModalController, AlertController, LoadingController } from '@ionic/angular';
|
||||||
import { VERSION } from '../../version';
|
import { VERSION } from '../../version';
|
||||||
import { PlaylistService } from '../../services/playlist.service';
|
import { PlaylistService } from '../../services/playlist.service';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { filter, first } from 'rxjs/operators';
|
||||||
|
|
||||||
import { MyCantiService } from '../../services/my-canti.service';
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
import { CantiLettureService } from '../../services/canti-letture.service';
|
import { CantiLettureService } from '../../services/canti-letture.service';
|
||||||
import { ComunitaService } from '../../services/comunita.service';
|
import { ComunitaService } from '../../services/comunita.service';
|
||||||
import { environment } from '../../../environments/environment';
|
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({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
@@ -33,6 +37,7 @@ export class SettingsPage {
|
|||||||
private toastCtrl = inject(ToastController);
|
private toastCtrl = inject(ToastController);
|
||||||
private modalCtrl = inject(ModalController);
|
private modalCtrl = inject(ModalController);
|
||||||
private alertCtrl = inject(AlertController);
|
private alertCtrl = inject(AlertController);
|
||||||
|
private loadingCtrl = inject(LoadingController);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
public version = VERSION;
|
public version = VERSION;
|
||||||
@@ -50,61 +55,444 @@ export class SettingsPage {
|
|||||||
this.showIosInstructions = !this.showIosInstructions;
|
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) {
|
onModeChange(event: any) {
|
||||||
this.settingsService.setShowChordsDefault(event.detail.value === 'chords');
|
this.settingsService.setShowChordsDefault(event.detail.value === 'chords');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onNameChange(event: any) {
|
||||||
|
this.settingsService.setUserName(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
onMassChange(event: any) {
|
onMassChange(event: any) {
|
||||||
this.cantiLettureService.setSelectedMass(event.detail.value);
|
this.cantiLettureService.setSelectedMass(event.detail.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fullRefresh() {
|
|
||||||
// 1. Refresh JSON data
|
|
||||||
this.cantiService.refresh();
|
|
||||||
|
|
||||||
// 2. Refresh liturgical readings JSON
|
|
||||||
try {
|
async onComunitaToggleChange(event: any) {
|
||||||
await this.cantiLettureService.fetchData();
|
const checked = event.detail.checked;
|
||||||
} catch (err) {
|
if (checked) {
|
||||||
console.error('Failed to refresh liturgical readings:', err);
|
this.settingsService.comunitaEnabled.set(true);
|
||||||
|
localStorage.setItem('comunita-enabled', 'true');
|
||||||
|
} else {
|
||||||
|
this.settingsService.comunitaEnabled.set(false);
|
||||||
|
localStorage.setItem('comunita-enabled', 'false');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Refresh community data if a code is active
|
async editComunita() {
|
||||||
const comunitaCode = this.comunitaService.comunitaCode();
|
await this.comunitaService.setComunitaCode('');
|
||||||
if (comunitaCode) {
|
}
|
||||||
try {
|
|
||||||
await this.comunitaService.setComunitaCode(comunitaCode);
|
async saveComunitaCode(code: string) {
|
||||||
} catch (err) {
|
const trimmed = (code || '').trim();
|
||||||
console.error('Failed to refresh community data:', err);
|
if (!trimmed) return;
|
||||||
}
|
|
||||||
}
|
const loading = await this.loadingCtrl.create({
|
||||||
|
message: 'Caricamento 0%',
|
||||||
// 4. Check for Service Worker updates
|
cssClass: 'premium-loading',
|
||||||
if (this.swUpdate.isEnabled) {
|
spinner: 'crescent'
|
||||||
try {
|
});
|
||||||
const updateFound = await this.swUpdate.checkForUpdate();
|
await loading.present();
|
||||||
if (updateFound) {
|
|
||||||
const toast = await this.toastCtrl.create({
|
let progressInterval = setInterval(() => {
|
||||||
message: 'Nuova versione disponibile! Aggiornamento in corso...',
|
const pct = this.comunitaService.loadingProgress();
|
||||||
duration: 2000,
|
loading.message = `Caricamento ${pct}%`;
|
||||||
color: 'secondary'
|
if (pct >= 100) {
|
||||||
});
|
clearInterval(progressInterval);
|
||||||
await toast.present();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to check for updates', err);
|
|
||||||
}
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const success = await this.comunitaService.setComunitaCode(trimmed);
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
await loading.dismiss();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: `Comunità attivata: ${this.comunitaService.comunitaNome()}`,
|
||||||
|
duration: 2000,
|
||||||
|
color: 'success'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
} else {
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'Codice non trovato o errore di connessione.',
|
||||||
|
duration: 2000,
|
||||||
|
color: 'danger'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeComunita() {
|
||||||
|
await this.comunitaService.setComunitaCode('');
|
||||||
|
this.settingsService.comunitaEnabled.set(false);
|
||||||
|
localStorage.setItem('comunita-enabled', 'false');
|
||||||
const toast = await this.toastCtrl.create({
|
const toast = await this.toastCtrl.create({
|
||||||
message: 'Dati aggiornati correttamente!',
|
message: 'Comunità disattivata.',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
color: 'success'
|
color: 'secondary'
|
||||||
});
|
});
|
||||||
await toast.present();
|
await toast.present();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ export class AudioEngineService {
|
|||||||
public searchTranscript = signal<string>('');
|
public searchTranscript = signal<string>('');
|
||||||
public isSearching = signal<boolean>(false);
|
public isSearching = signal<boolean>(false);
|
||||||
public sensitivity = signal<number>(75); // Default sensitivity (50-100)
|
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() {
|
public clearSearchTranscript() {
|
||||||
this.searchTranscript.set('');
|
this.searchTranscript.set('');
|
||||||
@@ -26,6 +42,7 @@ export class AudioEngineService {
|
|||||||
private isSpeaking: boolean = false;
|
private isSpeaking: boolean = false;
|
||||||
private lastSilenceTime: number = Date.now();
|
private lastSilenceTime: number = Date.now();
|
||||||
private lastWordTime: number = 0;
|
private lastWordTime: number = 0;
|
||||||
|
private speakingStartTime: number = 0;
|
||||||
|
|
||||||
// Constants for tuning - Optimized for close proximity (singer/guitarist)
|
// Constants for tuning - Optimized for close proximity (singer/guitarist)
|
||||||
private readonly SILENCE_GAP = 100; // ms
|
private readonly SILENCE_GAP = 100; // ms
|
||||||
@@ -33,11 +50,66 @@ export class AudioEngineService {
|
|||||||
private readonly COOLDOWN = 1000; // ms
|
private readonly COOLDOWN = 1000; // ms
|
||||||
private peakEnergy: number = 0;
|
private peakEnergy: number = 0;
|
||||||
private lineStarted: boolean = false;
|
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() {
|
constructor() {
|
||||||
this.initSpeechRecognition();
|
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() {
|
private initSpeechRecognition() {
|
||||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
if (SpeechRecognition) {
|
if (SpeechRecognition) {
|
||||||
@@ -84,93 +156,446 @@ export class AudioEngineService {
|
|||||||
async startListening() {
|
async startListening() {
|
||||||
if (this.isListening()) return;
|
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 {
|
try {
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: 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();
|
this.audioContext = new AudioContext();
|
||||||
const source = this.audioContext.createMediaStreamSource(this.stream);
|
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 = 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);
|
source.connect(this.analyser);
|
||||||
this.isListening.set(true);
|
|
||||||
this.processAudio();
|
this.processAudio();
|
||||||
} catch (err) {
|
} 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.');
|
alert('Errore microfono: assicurati di usare HTTPS e di aver dato i permessi.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopListening() {
|
stopListening() {
|
||||||
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
||||||
|
|
||||||
|
// Rilascia le risorse del microfono locale
|
||||||
this.stream?.getTracks().forEach(track => track.stop());
|
this.stream?.getTracks().forEach(track => track.stop());
|
||||||
this.audioContext?.close();
|
this.audioContext?.close();
|
||||||
|
this.stream = null;
|
||||||
|
this.audioContext = null;
|
||||||
|
this.analyser = null;
|
||||||
|
|
||||||
this.isListening.set(false);
|
this.isListening.set(false);
|
||||||
this.energyLevel.set(0);
|
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() {
|
private processAudio() {
|
||||||
if (!this.analyser) return;
|
if (!this.analyser) return;
|
||||||
|
|
||||||
const bufferLength = this.analyser.frequencyBinCount;
|
const bufferLength = this.analyser.frequencyBinCount; // 2048 con fftSize=4096
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
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 = () => {
|
const analyze = () => {
|
||||||
this.analyser!.getByteFrequencyData(dataArray);
|
if (!this.analyser) return;
|
||||||
|
this.analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
// Calculate average energy (volume)
|
// Calcola energia in ciascuna delle 8 sotto-bande
|
||||||
let sum = 0;
|
const bandEnergies: number[] = this.BANDS.map(band =>
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
this.getBandEnergy(dataArray, binWidth, band.start, band.end)
|
||||||
sum += dataArray[i];
|
);
|
||||||
}
|
|
||||||
const avgEnergy = sum / bufferLength;
|
// Energia totale media
|
||||||
this.energyLevel.set(avgEnergy);
|
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();
|
const now = Date.now();
|
||||||
// Balanced mapping: 0% -> 220 (very quiet), 100% -> 20 (very sensitive)
|
const hasSound = totalEnergy > MIN_ENERGY;
|
||||||
const currentThreshold = 220 - (this.sensitivity() * 2.0);
|
|
||||||
|
|
||||||
if (avgEnergy > currentThreshold) {
|
if (hasSound) {
|
||||||
if (avgEnergy > this.peakEnergy) {
|
// Calcola il punteggio voce multi-feature
|
||||||
this.peakEnergy = avgEnergy;
|
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) {
|
if (!this.isSpeaking) {
|
||||||
this.isSpeaking = true;
|
this.isSpeaking = true;
|
||||||
this.peakEnergy = avgEnergy;
|
this.speakingStartTime = now;
|
||||||
|
this.peakEnergy = totalEnergy;
|
||||||
this.lastSilenceTime = now;
|
this.lastSilenceTime = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se siamo in "speaking" e sentiamo un calo significativo rispetto al picco recente (almeno 30% di calo)
|
// Rilevamento della caduta di energia relativa per stacchi sillabici
|
||||||
// Questo permette di avanzare anche se c'è rumore di fondo sopra la soglia base.
|
const dropRatio = (this.peakEnergy - totalEnergy) / this.peakEnergy;
|
||||||
const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy;
|
if (this.isSpeaking && dropRatio > 0.20 && this.peakEnergy > currentThreshold * 1.1) {
|
||||||
if (this.isSpeaking && dropRatio > 0.35 && this.peakEnergy > currentThreshold * 1.2) {
|
const speakDuration = now - this.speakingStartTime;
|
||||||
if (now - this.lastWordTime > this.COOLDOWN) {
|
if (now - this.lastWordTime > this.COOLDOWN && speakDuration > 120) {
|
||||||
this.linesDetected.update(v => v + 1);
|
this.linesDetected.update(v => v + 1);
|
||||||
this.lastWordTime = now;
|
this.lastWordTime = now;
|
||||||
this.peakEnergy = avgEnergy; // Reset peak
|
this.peakEnergy = totalEnergy;
|
||||||
this.isSpeaking = false;
|
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;
|
this.lastSilenceTime = now;
|
||||||
} else {
|
} else {
|
||||||
// Sotto soglia (silenzio per il sistema)
|
// Sotto soglia, chitarra, o silenzio
|
||||||
if (this.isSpeaking && (now - this.lastSilenceTime > 200)) {
|
if (this.isSpeaking && (now - this.lastSilenceTime > 180)) {
|
||||||
if (now - this.lastWordTime > this.COOLDOWN) {
|
// 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.linesDetected.update(v => v + 1);
|
||||||
this.lastWordTime = now;
|
this.lastWordTime = now;
|
||||||
console.log('Line advanced - silence detected');
|
console.log('[VoiceDetect] Word counted - voice silence gap', { duration: actualSoundDuration });
|
||||||
}
|
}
|
||||||
this.isSpeaking = false;
|
this.isSpeaking = false;
|
||||||
this.peakEnergy = 0;
|
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);
|
this.animationFrame = requestAnimationFrame(analyze);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class CantiLettureService {
|
|||||||
public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso
|
public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso
|
||||||
public suggestionsMomentsMap = signal<Map<number, string[]>>(new Map()); // id_canto -> moments[]
|
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';
|
private SECURE_JSON_URL = '/api/cantiletture.json';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, signal, inject } from '@angular/core';
|
import { Injectable, signal, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpEventType } from '@angular/common/http';
|
import { HttpClient, HttpEventType } from '@angular/common/http';
|
||||||
import { Storage } from '@ionic/storage-angular';
|
import { Storage } from '@ionic/storage-angular';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
export interface Canto {
|
export interface Canto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +14,7 @@ export interface Canto {
|
|||||||
id_momenti?: number[];
|
id_momenti?: number[];
|
||||||
data_update?: string;
|
data_update?: string;
|
||||||
nonValidato?: boolean;
|
nonValidato?: boolean;
|
||||||
|
isPersonal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Indice {
|
export interface Indice {
|
||||||
@@ -33,6 +35,7 @@ export interface CantoEseguito {
|
|||||||
export class CantiService {
|
export class CantiService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private storage = inject(Storage);
|
private storage = inject(Storage);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
private _storage: Storage | null = null;
|
private _storage: Storage | null = null;
|
||||||
public canti = signal<Canto[]>([]);
|
public canti = signal<Canto[]>([]);
|
||||||
@@ -43,6 +46,7 @@ export class CantiService {
|
|||||||
public momenti = signal<Indice[]>([]);
|
public momenti = signal<Indice[]>([]);
|
||||||
public loading = signal<boolean>(false);
|
public loading = signal<boolean>(false);
|
||||||
public progress = signal<number>(0);
|
public progress = signal<number>(0);
|
||||||
|
public firstLoadCompleted = signal<boolean>(false);
|
||||||
|
|
||||||
private API_URL = 'https://www.canticristiani.it/api/canti.json';
|
private API_URL = 'https://www.canticristiani.it/api/canti.json';
|
||||||
|
|
||||||
@@ -54,6 +58,20 @@ export class CantiService {
|
|||||||
const storage = await this.storage.create();
|
const storage = await this.storage.create();
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
await this.loadFromStorage();
|
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();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +98,16 @@ export class CantiService {
|
|||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: async (event: any) => {
|
next: async (event: any) => {
|
||||||
if (event.type === HttpEventType.DownloadProgress) {
|
if (event.type === HttpEventType.DownloadProgress) {
|
||||||
|
let pct = 0;
|
||||||
if (event.total) {
|
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 {
|
} else {
|
||||||
this.progress.update(p => p < 90 ? p + 5 : p);
|
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) {
|
} else if (event.type === HttpEventType.Response) {
|
||||||
const response = event.body;
|
const response = event.body;
|
||||||
@@ -131,11 +155,13 @@ export class CantiService {
|
|||||||
}
|
}
|
||||||
this.progress.set(100);
|
this.progress.set(100);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.firstLoadCompleted.set(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error('Failed to fetch canti', error);
|
console.error('Failed to fetch canti', error);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.firstLoadCompleted.set(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ export class ComunitaService {
|
|||||||
link_youtube: cp.link_youtube || '',
|
link_youtube: cp.link_youtube || '',
|
||||||
id_momenti: [],
|
id_momenti: [],
|
||||||
data_update: cp.data_update || '',
|
data_update: cp.data_update || '',
|
||||||
nonValidato: true
|
nonValidato: Number(cp.stato) === 10,
|
||||||
|
isPersonal: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.comunitaCode.set(trimmedCode);
|
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 {
|
export interface ChordSegment {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -20,6 +21,7 @@ export interface ParsedSection {
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class LyricsParserService {
|
export class LyricsParserService {
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse plain text (campo 'testo') into structured sections.
|
* Parse plain text (campo 'testo') into structured sections.
|
||||||
@@ -43,36 +45,77 @@ export class LyricsParserService {
|
|||||||
let currentLines: ParsedLine[] = [];
|
let currentLines: ParsedLine[] = [];
|
||||||
let verseNumCounter = 0;
|
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 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) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
||||||
// Detect section start tags
|
const startMatch = trimmed.match(startTagRegex);
|
||||||
if (trimmed === '{start_verse}' || trimmed === '{sov}') {
|
if (startMatch) {
|
||||||
this.pushSection(sections, currentType, currentLines);
|
this.pushSection(sections, currentType, currentLines);
|
||||||
currentType = 'verse';
|
|
||||||
currentLines = [];
|
const tag = startMatch[1];
|
||||||
continue;
|
if (tag === 'start_verse' || tag === 'sov') {
|
||||||
}
|
currentType = 'verse';
|
||||||
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
|
} else if (tag === 'start_chorus' || tag === 'soc') {
|
||||||
this.pushSection(sections, currentType, currentLines);
|
currentType = 'chorus';
|
||||||
currentType = 'chorus';
|
} else if (tag === 'start_verse_num') {
|
||||||
currentLines = [];
|
currentType = 'verse_num';
|
||||||
continue;
|
verseNumCounter++;
|
||||||
}
|
}
|
||||||
if (trimmed === '{start_verse_num}') {
|
|
||||||
this.pushSection(sections, currentType, currentLines);
|
|
||||||
currentType = 'verse_num';
|
|
||||||
verseNumCounter++;
|
|
||||||
currentLines = [];
|
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;
|
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 (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);
|
this.pushSection(sections, currentType, currentLines, currentType === 'verse_num' ? verseNumCounter : undefined);
|
||||||
currentLines = [];
|
currentLines = [];
|
||||||
|
currentRawLines = [];
|
||||||
|
currentAction = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +129,33 @@ export class LyricsParserService {
|
|||||||
continue;
|
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
|
// Parse line
|
||||||
if (withChords) {
|
if (withChords) {
|
||||||
currentLines.push(this.parseChordLine(line));
|
currentLines.push(this.parseChordLine(resolvedLine));
|
||||||
} else {
|
} else {
|
||||||
// CLEAN CHORDS in text-only mode: remove [anything]
|
// CLEAN CHORDS in text-only mode: remove [anything]
|
||||||
const cleanLine = line.replace(/\[[^\]]*\]/g, '').trim();
|
const cleanLine = resolvedLine.replace(/\[[^\]]*\]/g, '').trim();
|
||||||
if (cleanLine.length > 0) {
|
if (cleanLine.length > 0) {
|
||||||
currentLines.push({
|
currentLines.push({
|
||||||
text: cleanLine,
|
text: cleanLine,
|
||||||
@@ -147,7 +211,7 @@ export class LyricsParserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add the chord as a new segment (text will be filled by next text chunk)
|
// 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;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +244,7 @@ export class LyricsParserService {
|
|||||||
* Handles Italian notation.
|
* Handles Italian notation.
|
||||||
*/
|
*/
|
||||||
transposeChord(chord: string, semitones: number): string {
|
transposeChord(chord: string, semitones: number): string {
|
||||||
if (!chord || semitones === 0) return chord;
|
if (!chord) return chord;
|
||||||
|
|
||||||
// Handle slash chords (e.g., DO/SOL)
|
// Handle slash chords (e.g., DO/SOL)
|
||||||
if (chord.includes('/')) {
|
if (chord.includes('/')) {
|
||||||
@@ -193,8 +257,9 @@ export class LyricsParserService {
|
|||||||
let root = '';
|
let root = '';
|
||||||
let suffix = '';
|
let suffix = '';
|
||||||
|
|
||||||
|
const upperChord = chord.toUpperCase();
|
||||||
for (const r of possibleRoots) {
|
for (const r of possibleRoots) {
|
||||||
if (chord.startsWith(r)) {
|
if (upperChord.startsWith(r.toUpperCase())) {
|
||||||
root = r;
|
root = r;
|
||||||
suffix = chord.substring(r.length);
|
suffix = chord.substring(r.length);
|
||||||
break;
|
break;
|
||||||
@@ -203,16 +268,40 @@ export class LyricsParserService {
|
|||||||
|
|
||||||
if (!root) return chord;
|
if (!root) return chord;
|
||||||
|
|
||||||
let index = this.scale.indexOf(root);
|
const upperRoot = root.toUpperCase();
|
||||||
if (index === -1) index = this.flatScale.indexOf(root);
|
let index = this.scale.indexOf(upperRoot);
|
||||||
|
if (index === -1) {
|
||||||
|
index = this.flatScale.findIndex(n => n.toUpperCase() === upperRoot);
|
||||||
|
}
|
||||||
if (index === -1) return chord;
|
if (index === -1) return chord;
|
||||||
|
|
||||||
let newIndex = (index + semitones) % 12;
|
let newIndex = (index + semitones) % 12;
|
||||||
if (newIndex < 0) newIndex += 12;
|
if (newIndex < 0) newIndex += 12;
|
||||||
|
|
||||||
// Preserve the original notation style (sharp or flat) if possible
|
// Decide flat vs sharp notation based on SettingsService preference:
|
||||||
const useFlat = this.flatScale.includes(root);
|
const pref = this.settingsService.chordNotationPreference();
|
||||||
const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
|
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;
|
return newRoot + suffix;
|
||||||
}
|
}
|
||||||
@@ -221,8 +310,6 @@ export class LyricsParserService {
|
|||||||
* Transpose all chords in a parsed structure.
|
* Transpose all chords in a parsed structure.
|
||||||
*/
|
*/
|
||||||
transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] {
|
transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] {
|
||||||
if (semitones === 0) return sections;
|
|
||||||
|
|
||||||
return sections.map(section => ({
|
return sections.map(section => ({
|
||||||
...section,
|
...section,
|
||||||
lines: section.lines.map(line => ({
|
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 { Storage } from '@ionic/storage-angular';
|
||||||
import { Canto, CantiService } from './canti.service';
|
import { Canto, CantiService } from './canti.service';
|
||||||
import { ToastController } from '@ionic/angular';
|
import { ToastController } from '@ionic/angular';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { PlaylistService } from './playlist.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -11,6 +12,8 @@ export class MyCantiService {
|
|||||||
private storage = inject(Storage);
|
private storage = inject(Storage);
|
||||||
private cantiService = inject(CantiService);
|
private cantiService = inject(CantiService);
|
||||||
private toastController = inject(ToastController);
|
private toastController = inject(ToastController);
|
||||||
|
private injector = inject(Injector);
|
||||||
|
private playlistService!: PlaylistService;
|
||||||
|
|
||||||
private _storage: Storage | null = null;
|
private _storage: Storage | null = null;
|
||||||
public myCanti = signal<Canto[]>([]);
|
public myCanti = signal<Canto[]>([]);
|
||||||
@@ -32,35 +35,88 @@ export class MyCantiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveCanto(canto: Partial<Canto>) {
|
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
|
||||||
const current = this.myCanti();
|
const current = this.myCanti();
|
||||||
const newCanto: Canto = {
|
let updated: Canto[];
|
||||||
id: `my_${Date.now()}`,
|
let targetCanto: Canto;
|
||||||
id_canti: Date.now(), // Fake ID for internal logic
|
|
||||||
titolo: canto.titolo || 'Senza Titolo',
|
if (canto.id && canto.id.startsWith('my_')) {
|
||||||
testo: canto.testo || '',
|
// Update existing song
|
||||||
accordi: canto.accordi,
|
updated = current.map(c => {
|
||||||
autore: canto.autore,
|
if (c.id === canto.id) {
|
||||||
link_youtube: canto.link_youtube,
|
targetCanto = {
|
||||||
id_momenti: canto.id_momenti || []
|
...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',
|
||||||
|
testo: canto.testo || '',
|
||||||
|
accordi: canto.accordi,
|
||||||
|
autore: canto.autore,
|
||||||
|
link_youtube: canto.link_youtube,
|
||||||
|
id_momenti: canto.id_momenti || []
|
||||||
|
};
|
||||||
|
updated = [...current, targetCanto];
|
||||||
|
}
|
||||||
|
|
||||||
const updated = [...current, newCanto];
|
|
||||||
this.myCanti.set(updated);
|
this.myCanti.set(updated);
|
||||||
await this._storage?.set('my-canti', 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({
|
const toast = await this.toastController.create({
|
||||||
message: 'Canto salvato nei "Miei Canti"!',
|
message: 'Canto salvato nei "Miei Canti"!',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
color: 'success'
|
color: 'success'
|
||||||
});
|
});
|
||||||
toast.present();
|
toast.present();
|
||||||
|
|
||||||
|
return targetCanto!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCanto(id: string) {
|
async deleteCanto(id: string) {
|
||||||
const updated = this.myCanti().filter(c => c.id !== id);
|
const updated = this.myCanti().filter(c => c.id !== id);
|
||||||
this.myCanti.set(updated);
|
this.myCanti.set(updated);
|
||||||
await this._storage?.set('my-canti', 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() {
|
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 { Storage } from '@ionic/storage-angular';
|
||||||
import { Canto, CantiService } from './canti.service';
|
import { Canto, CantiService } from './canti.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
|
import { ToastController, AlertController } from '@ionic/angular';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { MyCantiService } from './my-canti.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -11,6 +14,11 @@ export class PlaylistService {
|
|||||||
private storage = inject(Storage);
|
private storage = inject(Storage);
|
||||||
private cantiService = inject(CantiService);
|
private cantiService = inject(CantiService);
|
||||||
private comunitaService = inject(ComunitaService);
|
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 selectionMode = signal<boolean>(false);
|
||||||
public selectedIds = signal<Set<string>>(new Set());
|
public selectedIds = signal<Set<string>>(new Set());
|
||||||
@@ -22,7 +30,12 @@ export class PlaylistService {
|
|||||||
public activeListName = signal<string | null>(null);
|
public activeListName = signal<string | null>(null);
|
||||||
public activePlaylistId = 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 _storage: Storage | null = null;
|
||||||
|
private initPromise!: Promise<void>;
|
||||||
|
|
||||||
// Community scalette exposed as playlists (only when community filter is active)
|
// Community scalette exposed as playlists (only when community filter is active)
|
||||||
public comunitaPlaylists = computed(() => {
|
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(() => {
|
public allPlaylists = computed(() => {
|
||||||
const community = this.comunitaPlaylists();
|
const community = this.comunitaPlaylists();
|
||||||
const personal = this.playlists();
|
const personal = this.playlists();
|
||||||
|
const remote = this.remotePlaylist();
|
||||||
|
|
||||||
|
let list = [...personal.map(p => ({ ...p, isComunita: false, isRemote: false }))];
|
||||||
if (community.length > 0) {
|
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() {
|
constructor() {
|
||||||
this.init();
|
this.initPromise = this.init();
|
||||||
|
|
||||||
// Watch for context changes to dynamically reload the correct playlists
|
// Watch for context changes to dynamically reload the correct playlists
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -62,7 +81,7 @@ export class PlaylistService {
|
|||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPlaylistsStorageKey(): string {
|
getPlaylistsStorageKey(): string {
|
||||||
const code = this.comunitaService.comunitaCode();
|
const code = this.comunitaService.comunitaCode();
|
||||||
const isCommunityActive = this.comunitaService.isFilterActive();
|
const isCommunityActive = this.comunitaService.isFilterActive();
|
||||||
if (code && isCommunityActive) {
|
if (code && isCommunityActive) {
|
||||||
@@ -86,6 +105,84 @@ export class PlaylistService {
|
|||||||
const lastKey = `lastPlaylist_${key}`;
|
const lastKey = `lastPlaylist_${key}`;
|
||||||
const last = await this._storage.get(lastKey);
|
const last = await this._storage.get(lastKey);
|
||||||
this.lastPlaylist.set(last || null);
|
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() {
|
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 key = this.getPlaylistsStorageKey();
|
||||||
const lastKey = `lastPlaylist_${key}`;
|
const lastKey = `lastPlaylist_${key}`;
|
||||||
const editId = this.activePlaylistId();
|
const editId = this.activePlaylistId();
|
||||||
@@ -123,7 +221,8 @@ export class PlaylistService {
|
|||||||
if (editId) {
|
if (editId) {
|
||||||
this.playlists.update(p => p.map(pl => {
|
this.playlists.update(p => p.map(pl => {
|
||||||
if (pl.id === editId) {
|
if (pl.id === editId) {
|
||||||
return { ...pl, name, ids };
|
const mergedSettings = songSettings || pl.songSettings || {};
|
||||||
|
return { ...pl, name, ids, songSettings: mergedSettings };
|
||||||
}
|
}
|
||||||
return pl;
|
return pl;
|
||||||
}));
|
}));
|
||||||
@@ -133,31 +232,203 @@ export class PlaylistService {
|
|||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
name,
|
name,
|
||||||
ids,
|
ids,
|
||||||
|
songSettings: songSettings || {},
|
||||||
createdAt: new Date()
|
createdAt: new Date()
|
||||||
};
|
};
|
||||||
this.playlists.update(p => [newPlaylist, ...p]);
|
this.playlists.update(p => [newPlaylist, ...p]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastPlaylist.set(newPlaylist);
|
if (setAsLast) {
|
||||||
|
this.lastPlaylist.set(newPlaylist);
|
||||||
|
}
|
||||||
this.activeListIds.set(ids);
|
this.activeListIds.set(ids);
|
||||||
this.activeListName.set(name);
|
this.activeListName.set(name);
|
||||||
await this._storage?.set(key, this.playlists());
|
await this._storage?.set(key, this.playlists());
|
||||||
await this._storage?.set(lastKey, newPlaylist);
|
if (setAsLast) {
|
||||||
|
await this._storage?.set(lastKey, newPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset selection mode, IDs and editing state after saving
|
// Reset selection mode, IDs and editing state after saving
|
||||||
this.selectedIds.set(new Set());
|
this.selectedIds.set(new Set());
|
||||||
this.selectionMode.set(false);
|
this.selectionMode.set(false);
|
||||||
this.activePlaylistId.set(newPlaylist.id);
|
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) {
|
async deletePlaylist(id: string) {
|
||||||
|
await this.initPromise;
|
||||||
|
if (id.startsWith('remote_')) {
|
||||||
|
await this.clearRemotePlaylist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const key = this.getPlaylistsStorageKey();
|
const key = this.getPlaylistsStorageKey();
|
||||||
this.playlists.update(p => p.filter(pl => pl.id !== id));
|
this.playlists.update(p => p.filter(pl => pl.id !== id));
|
||||||
await this._storage?.set(key, this.playlists());
|
await this._storage?.set(key, this.playlists());
|
||||||
|
|
||||||
|
// Sincronizza automaticamente in background
|
||||||
|
this.syncLocalDataToServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateQR(ids: string[], name: string): Promise<string> {
|
async saveRemotePlaylist(pl: any, customSongs: any[]) {
|
||||||
const data = this.getShareLink(ids, name);
|
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, {
|
return await QRCode.toDataURL(data, {
|
||||||
width: 400,
|
width: 400,
|
||||||
margin: 2,
|
margin: 2,
|
||||||
@@ -168,28 +439,130 @@ export class PlaylistService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getShareLink(ids: string[], name: string): string {
|
getShareLink(ids: string[], name: string, songSettings?: any): string {
|
||||||
const data = JSON.stringify({ name, ids });
|
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
|
// Use btoa safely for UTF-8 strings
|
||||||
const base64 = btoa(unescape(encodeURIComponent(data)));
|
const base64 = btoa(unescape(encodeURIComponent(data)));
|
||||||
|
|
||||||
// Always use the production URL for sharing links as requested
|
// Always use the production URL for sharing links as requested
|
||||||
const productionUrl = 'https://www.canticristiani.it';
|
const productionUrl = 'https://www.canticristiani.it';
|
||||||
return `${productionUrl}/?import=${base64}`;
|
return `${productionUrl}/?import=${base64}&openFirst=1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
processImportJson(json: any): boolean {
|
processImportJson(json: any): boolean {
|
||||||
if (json && json.name && json.ids) {
|
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.activeListIds.set(json.ids);
|
||||||
this.activeListName.set(json.name);
|
this.activeListName.set(json.name);
|
||||||
|
// Automatically save to the device!
|
||||||
|
this.savePlaylist(json.name, json.ids, json.songSettings, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sharePlaylistQR(ids: string[], name: string) {
|
async sharePlaylistQR(ids: string[], name: string, songSettings?: any) {
|
||||||
const qrImage = await this.generateQR(ids, name);
|
// Sincronizza istantaneamente sul server prima di generare/condividere la playlist
|
||||||
const shareLink = this.getShareLink(ids, name);
|
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`;
|
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -204,7 +577,21 @@ export class PlaylistService {
|
|||||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
||||||
});
|
});
|
||||||
} else {
|
} 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');
|
const link = document.createElement('a');
|
||||||
link.href = qrImage;
|
link.href = qrImage;
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
@@ -217,4 +604,25 @@ export class PlaylistService {
|
|||||||
|
|
||||||
async loadPlaylists() {
|
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);
|
public showUpdateDate = signal<boolean>(true);
|
||||||
|
|
||||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
/** 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 */
|
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
|
||||||
public enableAcousticAutoscroll = signal<boolean>(false);
|
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;
|
private wakeLock: any = null;
|
||||||
|
|
||||||
@@ -55,8 +73,38 @@ export class SettingsService {
|
|||||||
public isStandalone = signal<boolean>(false);
|
public isStandalone = signal<boolean>(false);
|
||||||
public isIos = signal<boolean>(false);
|
public isIos = signal<boolean>(false);
|
||||||
public isAndroid = signal<boolean>(false);
|
public isAndroid = signal<boolean>(false);
|
||||||
|
public isVersionCheckComplete = signal<boolean>(false);
|
||||||
|
|
||||||
constructor() {
|
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
|
// Detect PWA status
|
||||||
try {
|
try {
|
||||||
this.isStandalone.set(
|
this.isStandalone.set(
|
||||||
@@ -90,7 +138,7 @@ export class SettingsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Migration: force settings defaults once for existing users to match the new rules
|
// 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') {
|
if (localStorage.getItem(migrationKey) !== 'true') {
|
||||||
localStorage.setItem('show-chords-default', 'true');
|
localStorage.setItem('show-chords-default', 'true');
|
||||||
localStorage.setItem('fullscreen-mode', this.isIos().toString());
|
localStorage.setItem('fullscreen-mode', this.isIos().toString());
|
||||||
@@ -101,8 +149,9 @@ export class SettingsService {
|
|||||||
localStorage.setItem('invio-dati-statistici', 'false');
|
localStorage.setItem('invio-dati-statistici', 'false');
|
||||||
localStorage.setItem('show-tags-in-list', 'true');
|
localStorage.setItem('show-tags-in-list', 'true');
|
||||||
localStorage.setItem('show-update-date', 'true');
|
localStorage.setItem('show-update-date', 'true');
|
||||||
localStorage.setItem('enable-standard-autoscroll', 'true');
|
localStorage.setItem('enable-standard-autoscroll', 'false');
|
||||||
localStorage.setItem('enable-acoustic-autoscroll', 'false');
|
localStorage.setItem('enable-visual-autoscroll', 'true');
|
||||||
|
localStorage.setItem('chord-notation-preference', 'diesis');
|
||||||
|
|
||||||
// ThemeService high contrast default
|
// ThemeService high contrast default
|
||||||
localStorage.setItem('high-contrast', 'true');
|
localStorage.setItem('high-contrast', 'true');
|
||||||
@@ -110,6 +159,7 @@ export class SettingsService {
|
|||||||
localStorage.setItem(migrationKey, 'true');
|
localStorage.setItem(migrationKey, 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const savedChords = localStorage.getItem('show-chords-default');
|
const savedChords = localStorage.getItem('show-chords-default');
|
||||||
if (savedChords !== null) {
|
if (savedChords !== null) {
|
||||||
this.showChordsDefault.set(savedChords === 'true');
|
this.showChordsDefault.set(savedChords === 'true');
|
||||||
@@ -138,12 +188,8 @@ export class SettingsService {
|
|||||||
this.autoAdvance.set(true);
|
this.autoAdvance.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
|
// Keep screen always on by default and always active
|
||||||
if (savedKeepScreenOn !== null) {
|
this.keepScreenOn.set(true);
|
||||||
this.keepScreenOn.set(savedKeepScreenOn === 'true');
|
|
||||||
} else {
|
|
||||||
this.keepScreenOn.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
|
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
|
||||||
if (savedComunitaEnabled !== null) {
|
if (savedComunitaEnabled !== null) {
|
||||||
@@ -177,14 +223,35 @@ export class SettingsService {
|
|||||||
if (savedStandardAutoscroll !== null) {
|
if (savedStandardAutoscroll !== null) {
|
||||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||||
} else {
|
} else {
|
||||||
this.enableStandardAutoscroll.set(true);
|
this.enableStandardAutoscroll.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
|
const savedVisualAutoscroll = localStorage.getItem('enable-visual-autoscroll');
|
||||||
if (savedAcousticAutoscroll !== null) {
|
if (savedVisualAutoscroll !== null) {
|
||||||
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
|
this.enableVisualAutoscroll.set(savedVisualAutoscroll === 'true');
|
||||||
} else {
|
} 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)
|
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||||
@@ -210,29 +277,6 @@ export class SettingsService {
|
|||||||
effect(() => {
|
effect(() => {
|
||||||
const mode = this.fullscreenMode();
|
const mode = this.fullscreenMode();
|
||||||
localStorage.setItem('fullscreen-mode', mode.toString());
|
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(() => {
|
effect(() => {
|
||||||
@@ -252,7 +296,19 @@ export class SettingsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
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(() => {
|
effect(() => {
|
||||||
@@ -291,9 +347,6 @@ export class SettingsService {
|
|||||||
localStorage.setItem('show-editor', newValue.toString());
|
localStorage.setItem('show-editor', newValue.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleKeepScreenOn() {
|
|
||||||
this.keepScreenOn.update(v => !v);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleComunitaEnabled() {
|
toggleComunitaEnabled() {
|
||||||
const newValue = !this.comunitaEnabled();
|
const newValue = !this.comunitaEnabled();
|
||||||
@@ -375,10 +428,40 @@ export class SettingsService {
|
|||||||
localStorage.setItem('enable-standard-autoscroll', newValue.toString());
|
localStorage.setItem('enable-standard-autoscroll', newValue.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAcousticAutoscroll() {
|
toggleVisualAutoscroll() {
|
||||||
const newValue = !this.enableAcousticAutoscroll();
|
const newValue = !this.enableVisualAutoscroll();
|
||||||
this.enableAcousticAutoscroll.set(newValue);
|
this.enableVisualAutoscroll.set(newValue);
|
||||||
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
|
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() {
|
async installPwa() {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Injectable, signal, effect } from '@angular/core';
|
|||||||
export class ThemeService {
|
export class ThemeService {
|
||||||
public highContrast = signal<boolean>(true);
|
public highContrast = signal<boolean>(true);
|
||||||
|
|
||||||
|
/** Whether the system prefers dark mode */
|
||||||
|
private systemPrefersDark = signal<boolean>(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Load from localStorage
|
// Load from localStorage
|
||||||
const saved = localStorage.getItem('high-contrast');
|
const saved = localStorage.getItem('high-contrast');
|
||||||
@@ -15,18 +18,49 @@ export class ThemeService {
|
|||||||
this.highContrast.set(true);
|
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(() => {
|
effect(() => {
|
||||||
const isHigh = this.highContrast();
|
const isHigh = this.highContrast();
|
||||||
if (typeof document !== 'undefined' && document.body) {
|
if (typeof document !== 'undefined') {
|
||||||
|
const root = document.documentElement;
|
||||||
if (isHigh) {
|
if (isHigh) {
|
||||||
document.body.classList.add('high-contrast');
|
root.classList.add('high-contrast');
|
||||||
|
if (document.body) document.body.classList.add('high-contrast');
|
||||||
} else {
|
} 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());
|
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() {
|
toggleContrast() {
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ export class YoutubePlayerService {
|
|||||||
if (!this.connectivityService.isOnline()) {
|
if (!this.connectivityService.isOnline()) {
|
||||||
return false;
|
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;
|
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 = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
contactEmail: 'info@canticristiani.it',
|
contactEmail: 'info@canticristiani.it',
|
||||||
appName: 'CantiCristiani'
|
appName: 'CantiCristiani',
|
||||||
|
apiAuthUser: 'canti',
|
||||||
|
apiAuthPass: 'antani2026'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
contactEmail: 'info@canticristiani.it',
|
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.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 {
|
ion-header {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@@ -66,14 +66,124 @@ ion-app {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* High Contrast Mode Overrides */
|
/* High Contrast Mode Overrides */
|
||||||
|
html.high-contrast, body.high-contrast {
|
||||||
|
color-scheme: light !important;
|
||||||
|
}
|
||||||
|
|
||||||
body.high-contrast {
|
body.high-contrast {
|
||||||
--ion-background-color: #ffffff;
|
--ion-background-color: #ffffff;
|
||||||
--ion-background-color-rgb: 255, 255, 255;
|
--ion-background-color-rgb: 255, 255, 255;
|
||||||
--ion-text-color: #000000;
|
--ion-text-color: #000000;
|
||||||
--ion-text-color-rgb: 0, 0, 0;
|
--ion-text-color-rgb: 0, 0, 0;
|
||||||
|
|
||||||
|
/* Primary color - complete set for shadow DOM components */
|
||||||
--ion-color-primary: #000000;
|
--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 {
|
.bg-gradient {
|
||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
@@ -263,6 +373,19 @@ body.high-contrast {
|
|||||||
color: #000000 !important;
|
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 {
|
.offline-badge-header {
|
||||||
|
|||||||
+168
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
<base href="/"/>
|
<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="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="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"/>
|
<meta name="format-detection" content="telephone=no"/>
|
||||||
@@ -26,8 +34,168 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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>
|
<app-root></app-root>
|
||||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+22
@@ -8,5 +8,27 @@ if (environment.production) {
|
|||||||
enableProdMode();
|
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)
|
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||||
.catch(err => console.log(err));
|
.catch(err => console.log(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user