Initial commit: fork from canti

This commit is contained in:
David Frassi
2026-09-26 09:00:54 +02:00
commit 34bf3ea391
154 changed files with 45131 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Regole del Progetto
Quando trovi dei trattini verticali `|` tra gli accordi, ricordati di inserirli all'interno della riga degli accordi (cioè racchiusi tra parentesi quadre, ad esempio come accordo `[|]`), non come testo normale.
+15
View File
@@ -0,0 +1,15 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.dev/reference/versions#browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
Chrome >=107
Firefox >=106
Edge >=107
Safari >=16.1
iOS >=16.1
+16
View File
@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+9
View File
@@ -0,0 +1,9 @@
FTP_PASSWORD=cantiDavid@72
FTP_THREADS=100
BASE_HREF=/ionic/
CONTACT_EMAIL=info@canticristiani.it
API_AUTH_USER=canti
API_AUTH_PASS=antani2026
VPS_HOST=185.193.67.105
VPS_USER=root
VPS_PATH=/var/docker/canticristiani-pwa/html
+47
View File
@@ -0,0 +1,47 @@
{
"root": true,
"ignorePatterns": ["projects/**/*"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"project": ["tsconfig.json"],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/prefer-standalone": "off",
"@angular-eslint/component-class-suffix": [
"error",
{
"suffixes": ["Page", "Component"]
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@angular-eslint/template/recommended"],
"rules": {}
}
]
}
+70
View File
@@ -0,0 +1,70 @@
# Specifies intentionally untracked files to ignore when using Git
# http://git-scm.com/docs/gitignore
*~
*.sw[mnpcod]
.tmp
*.tmp
*.tmp.*
UserInterfaceState.xcuserstate
$RECYCLE.BIN/
*.log
log.txt
/.sourcemaps
/.versions
/coverage
# Ionic
/.ionic
/www
/platforms
/plugins
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-project
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular
/.angular/cache
.sass-cache/
/.nx
/.nx/cache
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"Webnative.webnative"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
}
+13
View File
@@ -0,0 +1,13 @@
FROM node:22-bullseye
# Install Ionic CLI
RUN npm install -g @ionic/cli @angular/cli
# Set working directory
WORKDIR /app
# Expose ports for Ionic dev server and live reload
EXPOSE 8100 8101 35729
# Start command (will be overridden by docker-compose for initial setup)
CMD ["ionic", "serve", "--host", "0.0.0.0", "--port", "8100"]
+48
View File
@@ -0,0 +1,48 @@
# CantiCristiani PWA
Applicazione PWA moderna per la gestione di canti, accordi e karaoke integrato. Progettata per musicisti e cantanti per un uso fluido durante le celebrazioni o i momenti di preghiera.
## Caratteristiche Principali
- 🎤 **Karaoke Intelligente**: Avanzamento automatico del testo basato sul rilevamento vocale (microfono).
- 🎚️ **Sensibilità Dinamica**: Regolazione in tempo reale della sensibilità del microfono tramite slider verticale custom (50% - 100%).
- 🎼 **Accordi e Trasporto**: Visualizzazione accordi con possibilità di trasporto tonale istantaneo.
- 📋 **Gestione Playlist**: Creazione, modifica e condivisione di playlist tramite QR Code.
- 📱 **PWA Ready**: Installabile su Android e iOS come app nativa, con supporto offline.
- 🔆 **Schermo Sempre Acceso**: Opzione dedicata nelle impostazioni per evitare lo spegnimento dello schermo durante l'uso.
- 🔍 **Ricerca Rapida**: Filtri avanzati per categorie, tonalità e playlist.
## Tecnologie Utilizzate
- **Framework**: Ionic + Angular
- **State Management**: Angular Signals
- **PWA**: @angular/pwa
- **API**: Screen Wake Lock API, Media Session API
## Installazione e Sviluppo
### Prerequisiti
- Node.js (v18+)
- Ionic CLI
### Comandi Locali
```bash
npm install
ionic serve
```
## Deploy
Per distribuire l'applicazione in produzione (ambiente locale o server FTP):
```bash
./deploy_pwa.sh
```
Lo script si occupa di:
1. Incrementare la versione dell'app.
2. Eseguire la build di produzione.
3. Distribuire i file nella cartella di distribuzione locale.
---
© 2026 CantiCristiani.it
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# --- Caricamento variabili d'ambiente ---
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
else
echo "❌ Errore: File .env non trovato."
exit 1
fi
# --- Configurazione FTP ---
FTP_HOST="ftp.canticristiani.it"
FTP_USER="canticristiani.it"
FTP_PASS="$FTP_PASSWORD"
SOURCE_URL="https://libretto.mmcinet.eu/canti/api/get_all_app_tables"
REMOTE_PATH="api/canti.json"
if [ -z "$FTP_PASS" ]; then
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
exit 1
fi
# --- Download del file ---
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
TEMP_FILE=$(mktemp)
if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
echo "❌ Errore nel download dei dati."
rm "$TEMP_FILE"
exit 1
fi
# --- Rotazione e Caricamento file (FTP o VPS) ---
if [ -n "$VPS_HOST" ]; then
echo "🔄 Rotazione file su VPS ($VPS_HOST)..."
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api && mv $VPS_PATH/api/canti.json $VPS_PATH/api/canti_ex.json 2>/dev/null || true"
echo "🚀 Caricamento nuovo file su VPS via SCP..."
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
echo "✅ Allineamento su VPS completato con successo!"
else
echo "❌ Errore durante il caricamento via SCP su VPS."
rm "$TEMP_FILE"
exit 1
fi
else
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
# Rinominiamo canti.json in canti_ex.json.
# Usiamo i percorsi assoluti per sicurezza.
curl -s -u "$FTP_USER:$FTP_PASS" \
--ftp-pasv \
-Q "*DELE /htdocs/api/canti_ex.json" \
-Q "*RNFR /htdocs/api/canti.json" \
-Q "*RNTO /htdocs/api/canti_ex.json" \
"ftp://$FTP_HOST/" > /dev/null
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
echo "✅ Allineamento su FTP completato con successo!"
else
echo "❌ Errore durante il caricamento FTP."
rm "$TEMP_FILE"
exit 1
fi
fi
# Cleanup
rm "$TEMP_FILE"
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# --- Caricamento variabili d'ambiente ---
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
else
echo "❌ Errore: File .env non trovato."
exit 1
fi
# --- Configurazione FTP ---
FTP_HOST="ftp.canticristiani.it"
FTP_USER="canticristiani.it"
FTP_PASS="$FTP_PASSWORD"
SOURCE_URL="https://api.canticristiani.it/cantiletture.json"
REMOTE_PATH="api/cantiletture.json"
if [ -z "$FTP_PASS" ]; then
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
exit 1
fi
# --- Download del file ---
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
TEMP_FILE=$(mktemp)
if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"; then
echo "❌ Errore nel download dei dati."
rm "$TEMP_FILE"
exit 1
fi
# --- Caricamento file (FTP o VPS) ---
if [ -n "$VPS_HOST" ]; then
echo "🚀 Caricamento nuovo file su VPS ($VPS_HOST) via SCP..."
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api"
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
echo "✅ Allineamento su VPS completato con successo!"
else
echo "❌ Errore durante il caricamento via SCP su VPS."
rm "$TEMP_FILE"
exit 1
fi
else
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
echo "✅ Allineamento su FTP completato con successo!"
else
echo "❌ Errore durante il caricamento FTP."
rm "$TEMP_FILE"
exit 1
fi
fi
# Cleanup
rm "$TEMP_FILE"
+168
View File
@@ -0,0 +1,168 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"app": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "www",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*",
"input": "public",
"output": "."
},
{
"glob": "**/*.svg",
"input": "node_modules/ionicons/dist/ionicons/svg",
"output": "./svg"
}
],
"styles": ["src/global.scss", "src/theme/variables.scss"],
"scripts": [],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json",
"allowedCommonJsDependencies": [
"localforage",
"qrcode",
"mammoth",
"tesseract.js"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "32kb",
"maximumError": "48kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"ci": {
"progress": false
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
},
"ci": {
"progress": false
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "app:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*",
"input": "public",
"output": "."
},
{
"glob": "**/*.svg",
"input": "node_modules/ionicons/dist/ionicons/svg",
"output": "./svg"
}
],
"styles": ["src/global.scss", "src/theme/variables.scss"],
"scripts": []
},
"configurations": {
"ci": {
"progress": false,
"watch": false
}
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"cli": {
"schematicCollections": [
"@ionic/angular-toolkit"
],
"analytics": false
},
"schematics": {
"@ionic/angular-toolkit:component": {
"styleext": "scss"
},
"@ionic/angular-toolkit:page": {
"styleext": "scss"
}
}
}
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
echo "Creazione del backup del repository Git locale..."
zip -r git_backup.zip . -x "node_modules/*" ".angular/*" "www/*" "platforms/*" "plugins/*" ".ionic/*" "dist/*" ".nx/*" "*/.DS_Store" "git_backup.zip" "backup.sh"
echo "Backup completato! Creato il file git_backup.zip."
+224
View File
@@ -0,0 +1,224 @@
# Piano di Azione: Suggerimento Canti con AI (cantiletture.json)
## Obiettivo
Generare automaticamente, una volta a settimana, un file JSON con i canti suggeriti per ogni messa della settimana, analizzati da Gemini Flash a partire dalle letture liturgiche. La PWA scarica questo file statico e mostra i suggerimenti istantaneamente, senza alcun calcolo lato client.
---
## Architettura
```
CRON Settimanale (Lunedì ore 6:00)
│
├─ 1. Fetch RSS liturgia.silvestrini.org/rss
│ → Estrae le letture di tutta la settimana (7 giorni)
│
├─ 2. Fetch canti.json dal server
│ → Carica il database completo dei canti con testi e momenti liturgici
│
├─ 3. Chiamata API Gemini Flash
│ → Invia prompt strutturato con letture + lista canti
│ → Riceve suggerimenti ragionati con score e motivazione
│
└─ 4. Salva cantiletture.json
→ Upload sul server web (stessa posizione di canti.json)
```
---
## Struttura del File cantiletture.json
```json
{
"generated_at": "2026-05-19T06:00:00Z",
"week_start": "2026-05-17",
"week_end": "2026-05-24",
"masses": {
"2026-05-17": {
"title": "Ascensione del Signore",
"day": "Domenica",
"moments": {
"Ingresso": [
{
"id_canti": 42,
"titolo": "Cristo è risorto veramente",
"score": 95,
"motivo": "Il tema dell'ascensione e della gloria di Cristo risorto è centrale in questo canto."
},
{
"id_canti": 118,
"titolo": "Alleluia, è risorto",
"score": 88,
"motivo": "Il tono gioioso e la proclamazione pasquale si collegano al mistero celebrato."
},
{
"id_canti": 205,
"titolo": "Canto di lode al Signore",
"score": 82,
"motivo": "Richiama il tema della lode che accompagna l'ascensione di Gesù."
}
],
"Offertorio": [
{ "...": "3 canti suggeriti" }
],
"Comunione": [
{ "...": "3 canti suggeriti" }
],
"Finale": [
{ "...": "3 canti suggeriti" }
]
}
},
"2026-05-18": {
"title": "Lunedì della VII settimana di Pasqua",
"day": "Lunedì",
"moments": { "...": "stessa struttura" }
}
}
}
```
### Campi per ogni canto suggerito:
| Campo | Tipo | Descrizione |
|-------|------|-------------|
| `id_canti` | number | ID del canto nel database (per collegamento diretto al player) |
| `titolo` | string | Titolo del canto (per visualizzazione rapida) |
| `score` | number (0-100) | Grado di affinità/affidabilità del suggerimento |
| `motivo` | string | Spiegazione testuale del perché il canto è adatto |
---
## Componenti da Sviluppare
### 1. Script di Generazione (`scripts/generate-cantiletture.ts`)
Script Node.js/TypeScript che:
- **Input**: RSS feed + canti.json
- **Elaborazione**:
1. Parsing XML del feed RSS (estrazione letture per data)
2. Preparazione del prompt per Gemini con: testo letture + lista canti (id, titolo, testo, momenti liturgici)
3. Chiamata API Gemini Flash con output JSON strutturato
4. Validazione della risposta (verifica che gli ID canti esistano, score nel range)
- **Output**: File `cantiletture.json`
**Prompt di esempio per Gemini:**
```
Sei un esperto liturgista cattolico. Ti fornisco le letture della messa
e un database di canti liturgici con i relativi momenti (Ingresso,
Offertorio, Comunione, Finale, ecc.).
Per ogni giorno della settimana e per ogni momento liturgico, suggerisci
i 3 canti più adatti. Per ogni suggerimento indica:
- L'ID del canto (id_canti)
- Uno score da 0 a 100 che indica il grado di attinenza
- Una breve motivazione (max 1 frase)
Considera: temi teologici, periodo liturgico, tono emotivo,
corrispondenze tra letture e testi dei canti.
LETTURE DELLA SETTIMANA:
[...testo letture...]
DATABASE CANTI:
[...lista canti con id, titolo, testo, momenti...]
Rispondi SOLO con JSON valido nel formato specificato.
```
### 2. Configurazione API Key
- Ottenere API Key gratuita da [Google AI Studio](https://aistudio.google.com/apikey)
- Salvarla come variabile d'ambiente `GEMINI_API_KEY`
- Non committarla nel repository (usare `.env` in `.gitignore`)
### 3. Aggiornamento PWA
#### 3.1 Nuovo Service: `CantiLettureService`
```typescript
// Logica semplificata:
// - Scarica cantiletture.json dal server (con cache)
// - Espone i suggerimenti per data selezionata
// - Fallback al keyword matching se il JSON non è disponibile
```
#### 3.2 Aggiornamento LiturgiaPage
- Rimuovere il tasto "Analizza e Suggerisci Canti" (non serve più)
- Rimuovere la sezione "Parole Chiave Rilevate" (non serve più)
- Mostrare automaticamente i suggerimenti raggruppati per momento liturgico
- Ogni card mostra: titolo, score (barra o badge), motivazione dell'AI
- Click sulla card → apre il canto nel player
#### 3.3 Fallback
Se `cantiletture.json` non è disponibile o la data non è coperta:
- Mostrare un messaggio "Suggerimenti AI non disponibili per questa data"
- Opzionalmente, offrire il keyword matching come alternativa
### 4. Automazione (CRON)
#### Opzione A: Script locale con crontab
```bash
# Ogni lunedì alle 6:00
0 6 * * 1 cd /path/to/canti && node scripts/generate-cantiletture.js
```
#### Opzione B: GitHub Action (se il repo viene messo su GitHub)
```yaml
# .github/workflows/generate-suggestions.yml
name: Generate Canti Suggestions
on:
schedule:
- cron: '0 6 * * 1' # Ogni lunedì alle 6:00 UTC
workflow_dispatch: # Esecuzione manuale
```
#### Opzione C: Script manuale
```bash
# Eseguibile a mano quando si vuole aggiornare
./scripts/generate-cantiletture.sh
```
---
## Stima Costi
| Voce | Valore |
|------|--------|
| Chiamate API/settimana | 1 (una sola chiamata copre tutta la settimana) |
| Token input (letture + ~500 canti) | ~15.000 token |
| Token output (JSON suggerimenti) | ~3.000 token |
| Costo per chiamata (Gemini Flash) | ~0.001€ |
| **Costo mensile stimato** | **< 0.01€** |
| Rientra nell'abbonamento Google One | ✅ Sì |
---
## Piano di Esecuzione (Step by Step)
### Fase 1: Script di Generazione
1. [ ] Creare `scripts/generate-cantiletture.ts`
2. [ ] Implementare il parsing RSS
3. [ ] Implementare la chiamata Gemini con prompt ottimizzato
4. [ ] Testare con le letture della settimana corrente
5. [ ] Validare il JSON generato
### Fase 2: Integrazione PWA
6. [ ] Creare `CantiLettureService` che scarica e gestisce il JSON
7. [ ] Aggiornare `LiturgiaPage` per mostrare i suggerimenti AI
8. [ ] Implementare il fallback al keyword matching
9. [ ] Testare l'interfaccia completa
### Fase 3: Automazione
10. [ ] Configurare il cron/script di esecuzione automatica
11. [ ] Testare il ciclo completo (generazione → upload → visualizzazione)
12. [ ] Documentare la procedura nel README
---
## Note Tecniche
- **Dimensione stimata del JSON**: ~50-100 KB per settimana (7 giorni × 4-5 momenti × 3 canti)
- **Cache**: Il JSON viene cachato in localStorage con TTL di 24 ore
- **Compatibilità**: Il file viene servito dallo stesso server HTTP della PWA, nessun problema CORS
- **Retrocompatibilità**: Se il file non esiste, la PWA continua a funzionare con il keyword matching
+5
View File
@@ -0,0 +1,5 @@
{
"id_comunita": "123456",
"nome_comunita": "Comunità Test 123456",
"canti": [378, 379, 380]
}
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
# --- Configurazione ---
DIST_PATH="www" # Solitamente Ionic usa 'www' o 'dist/app-name'
APP_NAME="CantiCristiani"
echo "🚀 Inizio procedura di deploy PWA per: $APP_NAME"
# 1. Verifica dipendenze
if ! [ -x "$(command -v npm)" ]; then
echo "❌ Errore: npm non è installato." >&2
exit 1
fi
# 1.5 Aggiornamento Versione
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "export const VERSION = '$VERSION';" > src/app/version.ts
echo "🏷️ Versione aggiornata a: $VERSION"
# --- Caricamento variabili d'ambiente ---
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
# --- Configurazione Email e API Parametriche ---
node -e "
const fs = require('fs');
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
const apiUser = process.env.API_AUTH_USER || 'canti';
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
if (fs.existsSync(file)) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
fs.writeFileSync(file, content, 'utf8');
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
}
});
"
# 2. Build dell'applicazione
echo "📦 Compilazione in corso (Production Build)..."
if ! npm run build -- --configuration=production --base-href=/; then
echo "❌ Errore: La compilazione è fallita. Controlla gli errori sopra."
exit 1
fi
# Verifico se la cartella www esiste (Ionic/Angular)
if [ ! -d "www" ]; then
# Se non c'è www, cerco in dist/
DIST_PATH=$(ls -d dist/*/ 2>/dev/null | head -n 1)
if [ -z "$DIST_PATH" ]; then
echo "❌ Errore: Cartella di build non trovata. Controlla il comando 'npm run build'."
exit 1
fi
fi
echo "✅ Build completata con successo in: $DIST_PATH"
# --- Genera version.json per il polling PWA ---
BUILD_TIMESTAMP=$(date +%s)000
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
# 2.5 In locale usiamo la root
echo "📁 Build pronta nella root..."
# Nessuna sottocartella ionic necessaria in locale
# Nessun redirect necessario in locale
# 3. Recupero IP locale per connessione da cellulare
IP_ADDRESS=$(ipconfig getifaddr en0 || ipconfig getifaddr en1 || hostname -I | awk '{print $1}')
if [ -z "$IP_ADDRESS" ]; then
IP_ADDRESS="localhost"
fi
echo ""
echo "--------------------------------------------------------"
echo "📱 INSTALLAZIONE SU CELLULARE:"
echo "1. Assicurati che il cellulare sia sulla stessa rete Wi-Fi."
echo "2. Apri il browser (Chrome su Android, Safari su iOS)."
echo "3. Vai all'indirizzo: http://\$IP_ADDRESS:8080/"
echo " ⚠️ NOTA: Per usare il MICROFONO su cellulare, i browser richiedono HTTPS."
echo " Puoi usare uno strumento come 'ngrok' o 'local-ssl-proxy' per testarlo con HTTPS."
echo "4. Una volta caricata l'app:"
echo " - Android: Clicca sui 3 puntini -> 'Installa app' o 'Aggiungi a home'."
echo " - iOS: Clicca l'icona 'Condividi' (quadrato con freccia) -> 'Aggiungi a schermata Home'."
echo "--------------------------------------------------------"
echo ""
# 4. Avvio server locale per test immediato
echo "🌐 Liberazione porta 8080 (se occupata)..."
lsof -ti:8080 | xargs kill -9 2>/dev/null || true
echo "🌐 Avvio server di distribuzione su http://$IP_ADDRESS:8080/ ..."
echo "premi CTRL+C per fermare il server."
npx http-server "$DIST_PATH" -p 8080 -c-1 --proxy "http://localhost:8080?"
Executable
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# --- Caricamento variabili d'ambiente ---
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
else
echo "❌ Errore: File .env non trovato."
exit 1
fi
# --- Configurazione Target ---
TARGET="contabo"
echo "🎯 Target di deploy: $TARGET"
# --- Validazione configurazione ---
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
# --- Aggiornamento Versione ---
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "export const VERSION = '$VERSION';" > src/app/version.ts
echo "🏷️ Versione aggiornata a: $VERSION"
# --- Configurazione Email 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}\`);
}
});
"
# --- Build della PWA (base-href=/ per installare nella root) ---
echo "📦 1/2 Build della PWA (Production) per ROOT (www.canticristiani.it) in corso..."
npm run build -- --configuration=production --base-href=/ || { echo "❌ Errore nella build"; exit 1; }
if [ ! -d "www" ]; then
echo "❌ Errore: Cartella 'www' non trovata."
exit 1
fi
# --- 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)"
# --- Upload ---
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
# --- Allineamento Cantiletture JSON ---
if [ -f "./allinealetture.sh" ]; then
echo "🔄 Sincronizzazione cantiletture.json su server..."
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
fi
+17
View File
@@ -0,0 +1,17 @@
services:
app:
build: .
volumes:
- .:/app
- node_modules:/app/node_modules
ports:
- "8100:8100"
- "8101:8101"
- "35729:35729"
environment:
- CHOKIDAR_USEPOLLING=true
command: ionic serve --external --port 8100 --address 0.0.0.0
volumes:
node_modules:
+220
View File
@@ -0,0 +1,220 @@
# Studio di Dettaglio: Opzione 1 - White-Label / Multi-Configuration
Questo documento descrive in dettaglio la strategia, la configurazione e i passaggi necessari per implementare la clonazione del progetto **canti** per la variante **stereocomics** attraverso l'**Opzione 1: White-Label**.
Con questo approccio, il codice sorgente rimane **unico al 100%**. La differenziazione tra l'applicazione originale (Canti) e il clone (Stereocomics) avviene esclusivamente a tempo di compilazione (build-time) o di esecuzione (run-time) tramite file di configurazione ambientale di Angular (`environment`), fogli di stile dedicati, e sostituzione degli asset.
---
## 1. Architettura e Flusso di Configurazione
Il concetto chiave è l'utilizzo delle funzionalità native di Angular CLI (`angular.json`) per iniettare le configurazioni specifiche del brand.
```mermaid
graph TD
A[Codice Sorgente Comune] --> B{Build Command}
B -- "ng build" --> C[Canti App]
B -- "ng build --configuration=stereocomics" --> D[Stereocomics App]
subgraph Sostituzioni Stereocomics
E[environment.ts -> environment.stereocomics.ts]
F[variables.scss -> variables.stereocomics.scss]
G[Asset Generici -> Asset Stereocomics]
end
D -.-> Sostituzioni Stereocomics
```
---
## 2. Passaggi Operativi per l'Implementazione
### Passo 2.1: Creazione dei File Ambientali (Environments)
Attualmente in `src/environments/` abbiamo `environment.ts` e `environment.prod.ts`. Creeremo le varianti per Stereocomics:
#### [NEW] [environment.stereocomics.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/environments/environment.stereocomics.ts)
```typescript
export const environment = {
production: false,
contactEmail: 'info@stereocomics.it',
appName: 'Stereocomics',
apiAuthUser: 'stereocomics',
apiAuthPass: 'stereo2026',
brand: 'stereocomics' // Flag utile per abilitare o disabilitare funzionalità specifiche run-time
};
```
#### [NEW] [environment.stereocomics.prod.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/environments/environment.stereocomics.prod.ts)
```typescript
export const environment = {
production: true,
contactEmail: 'info@stereocomics.it',
appName: 'Stereocomics',
apiAuthUser: 'stereocomics',
apiAuthPass: 'stereo2026_prod',
brand: 'stereocomics'
};
```
---
### Passo 2.2: Configurazione di `angular.json`
Per permettere ad Angular di caricare la configurazione corretta, dobbiamo aggiungere una nuova build configuration in [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json).
Sotto `projects -> app -> architect -> build -> configurations`, aggiungeremo il blocco `stereocomics`:
```json
"stereocomics": {
"buildOptimizer": true,
"optimization": true,
"vendorChunk": false,
"extractLicenses": true,
"sourceMap": false,
"namedChunks": false,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.stereocomics.prod.ts"
},
{
"replace": "src/theme/variables.scss",
"with": "src/theme/variables.stereocomics.scss"
}
],
"assets": [
{
"glob": "**/*",
"input": "src/assets/stereocomics",
"output": "assets"
},
{
"glob": "**/*",
"input": "public/stereocomics",
"output": "."
}
]
}
```
E sotto `projects -> app -> architect -> serve -> configurations`:
```json
"stereocomics": {
"buildTarget": "app:build:development",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.stereocomics.ts"
},
{
"replace": "src/theme/variables.scss",
"with": "src/theme/variables.stereocomics.scss"
}
],
"assets": [
{
"glob": "**/*",
"input": "src/assets/stereocomics",
"output": "assets"
},
{
"glob": "**/*",
"input": "public/stereocomics",
"output": "."
}
]
}
```
---
### Passo 2.3: Stile e Theming
Creeremo un file di variabili SCSS specifico per ridefinire i colori primari di Ionic in chiave "Stereocomics" (es. arancione/viola invece del blu/verde tipico dei Canti).
#### [NEW] [variables.stereocomics.scss](file:///Users/davidfrassi/SRC/agenti/canti/src/theme/variables.stereocomics.scss)
Questo file conterrà le medesime variabili CSS di `src/theme/variables.scss` ma con la palette colori e i font scelti per Stereocomics:
```scss
// Stereocomics Palette
:root {
--ion-color-primary: #ff5722;
--ion-color-primary-rgb: 255,87,34;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255,255,255;
--ion-color-primary-shade: #e04d1d;
--ion-color-primary-tint: #ff6838;
// Font personalizzati
--app-font-family: 'Outfit', sans-serif;
}
```
Nel file [global.scss](file:///Users/davidfrassi/SRC/agenti/canti/src/global.scss) o nei componenti useremo la variabile `--app-font-family` per rendere dinamico il font.
---
### Passo 2.4: Gestione Asset e Icone PWA
Per evitare di caricare icone e manifest di CantiCristiani su Stereocomics:
1. Creeremo due sottocartelle in `src/assets/`:
- `src/assets/canti/` (per i loghi e immagini originali)
- `src/assets/stereocomics/` (per i loghi e immagini di Stereocomics)
2. Durante la build di `stereocomics`, mapperemo la cartella `src/assets/stereocomics` direttamente sull'output `assets/` (come specificato in `angular.json`), garantendo che i percorsi `/assets/logo.png` rimangano identici nel codice HTML, ma cambino fisicamente nel pacchetto generato.
3. Creeremo un file `manifest.stereocomics.webmanifest` in `public/stereocomics/manifest.webmanifest` contenente il nome "Stereocomics" e i riferimenti alle icone corrette per la PWA.
---
### Passo 2.5: Script in `package.json`
Aggiungeremo comandi dedicati in [package.json](file:///Users/davidfrassi/SRC/agenti/canti/package.json) per facilitare lo sviluppo e la pubblicazione:
```json
"scripts": {
"start:canti": "ng serve --configuration=development",
"start:stereocomics": "ng serve --configuration=stereocomics",
"build:canti": "ng build --configuration=production",
"build:stereocomics": "ng build --configuration=stereocomics"
}
```
---
### Passo 2.6: Gestione Capacitor (Mobile Native App)
Se l'app deve essere compilata per iOS/Android tramite Capacitor:
- Possiamo creare un file di configurazione dinamico `capacitor.config.ts` che esporta la configurazione a seconda di una variabile d'ambiente (es. `process.env['BRAND']`).
```typescript
import { CapacitorConfig } from '@capacitor/cli';
const brand = process.env['BRAND'] || 'canti';
const config: CapacitorConfig = {
appId: brand === 'stereocomics' ? 'it.stereocomics.app' : 'it.canticristiani.app',
appName: brand === 'stereocomics' ? 'Stereocomics' : 'Canti Cristiani',
webDir: 'www',
bundledWebRuntime: false
};
export default config;
```
---
## 3. Vantaggi e Svantaggi dell'Opzione 1
### Vantaggi:
1. **Zero Duplicazione di Codice**: Se viene corretto un bug nella pagina di riproduzione audio o nel parser dei testi, la modifica è istantaneamente attiva per entrambi i brand.
2. **Semplicità di Manutenzione**: Un'unica pipeline di CI/CD che esegue i test unitari una sola volta per la codebase comune.
3. **Flessibilità Controllata**: È comunque possibile introdurre comportamenti run-time personalizzati leggendo `environment.brand` nel codice TypeScript (es. `if (environment.brand === 'stereocomics') { ... }`).
### Svantaggi:
1. **Complessità Condizionale**: Se a lungo andare Stereocomics necessita di pagine con layout o logiche radicalmente diversi da Canti, il codice si riempirà di blocchi `if/else` o direttive condizionali (`*ngIf="isStereocomics"`), riducendo la leggibilità del codice.
---
## 4. Criteri di Accettazione e Verifica (Verification Plan)
### Verifica dello Sviluppo Locale
1. Eseguire `npm run start:canti` -> Verificare che il logo sia quello di CantiCristiani e il colore dominante sia il blu/verde originale.
2. Eseguire `npm run start:stereocomics` -> Verificare che l'interfaccia risponda con il branding Stereocomics (colore primario cambiato, nome app cambiato in testata, logo corretto).
### Verifica PWA e Build di Produzione
1. Eseguire `npm run build:stereocomics`.
2. Controllare che la cartella `/www` contenga il file `manifest.webmanifest` con i riferimenti a Stereocomics.
3. Verificare che l'email di contatto visualizzata sia `info@stereocomics.it`.
+175
View File
@@ -0,0 +1,175 @@
# Studio di Dettaglio: Opzione 2 - Angular Workspace Monorepo
Questo documento descrive in dettaglio la strategia, la configurazione e i passaggi necessari per implementare la clonazione del progetto **canti** per la variante **stereocomics** attraverso l'**Opzione 2: Angular Workspace Monorepo**.
Con questo approccio, il repository viene strutturato per ospitare **due applicazioni distinte** (`canti` e `stereocomics`) e **una o più librerie condivise** (`shared-core`) che conterranno la logica di business comune (servizi audio, parser di testi, database locale, integrazioni API).
---
## 1. Architettura e Struttura delle Cartelle
Nel modello Monorepo, la struttura dei file viene riorganizzata per isolare l'interfaccia utente (pagine, componenti, temi) e condividere i servizi fondamentali.
```mermaid
graph TD
subgraph Monorepo Workspace
Shared[Libreria Condivisa: @shared/core]
AppCanti[App 1: Canti]
AppStereo[App 2: Stereocomics]
end
AppCanti --> Shared
AppStereo --> Shared
```
La struttura delle cartelle diventerà simile alla seguente:
```text
canti/
├── angular.json
├── package.json
├── tsconfig.json
├── src/ <-- Diventa l'applicazione Canti originale
│ ├── app/
│ │ ├── pages/ <-- Pagine specifiche di Canti
│ │ └── app.module.ts
│ └── assets/ <-- Asset di Canti
├── projects/
│ ├── shared-core/ <-- Libreria condivisa generata
│ │ ├── src/
│ │ │ ├── public-api.ts
│ │ │ └── lib/
│ │ │ └── services/ <-- AudioEngine, LyricsParser, CantiService, ecc.
│ └── stereocomics/ <-- Nuova applicazione Stereocomics
│ ├── src/
│ │ ├── app/
│ │ │ ├── pages/ <-- Pagine e UI customizzate di Stereocomics
│ │ │ └── app.module.ts
│ │ ├── assets/ <-- Asset di Stereocomics
│ │ └── theme/ <-- Fogli di stile di Stereocomics
```
---
## 2. Passaggi Operativi per l'Implementazione
### Passo 2.1: Inizializzazione della Libreria Condivisa
Generiamo una libreria Angular per ospitare i servizi condivisi.
```bash
ng generate library shared-core --prefix=shared
```
Questo comando creerà la cartella `projects/shared-core/` e aggiornerà [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json) e [tsconfig.json](file:///Users/davidfrassi/SRC/agenti/canti/tsconfig.json) inserendo il path alias (es. `@shared/core`).
---
### Passo 2.2: Migrazione dei Servizi Core
Sposteremo tutti i servizi non legati direttamente alla UI da `src/app/services/` a `projects/shared-core/src/lib/services/`.
Servizi da migrare:
* [audio-engine.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/audio-engine.service.ts)
* [canti.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/canti.service.ts)
* [lyrics-parser.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/lyrics-parser.service.ts)
* Eventuali altri helper o interfacce dati comuni.
Esponiamo i servizi nel file `projects/shared-core/src/public-api.ts`:
```typescript
export * from './lib/services/audio-engine.service';
export * from './lib/services/canti.service';
export * from './lib/services/lyrics-parser.service';
```
Aggiorneremo quindi gli import all'interno dell'applicazione principale `canti` (e successivamente `stereocomics`):
```typescript
// Da:
import { CantiService } from '../services/canti.service';
// A:
import { CantiService } from '@shared/core';
```
---
### Passo 2.3: Generazione della Nuova Applicazione Stereocomics
Creiamo la seconda applicazione all'interno del workspace:
```bash
ng generate application stereocomics --routing --style=scss
```
In [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json) verrà aggiunto un nuovo progetto denominato `stereocomics`.
#### Integrazione Ionic
Per abilitare le funzionalità Ionic (componenti UI, gesture, ecc.) nella nuova applicazione, occorre:
1. Importare `IonicModule.forRoot()` nel file `projects/stereocomics/src/app/app.module.ts`.
2. Copiare o adattare la struttura di theming da `src/theme/` a `projects/stereocomics/src/theme/`.
---
### Passo 2.4: Personalizzazione delle Pagine e dei Componenti
A differenza dell'Opzione 1 (dove le pagine HTML sono identiche), nell'Opzione 2 `stereocomics` ha le sue pagine indipendenti in `projects/stereocomics/src/app/pages/`.
È possibile:
* Creare layout completamente differenti.
* Aggiungere nuove feature o pagine esclusive (es. una sezione "Comics" o "Store") senza intaccare minimamente l'applicazione `canti`.
* Importare i servizi core da `@shared/core` in ciascuna pagina per gestire la logica dei canti e dell'audio.
---
### Passo 2.5: Script in `package.json`
Modificheremo gli script di avvio e build per differenziare i target:
```json
"scripts": {
"start:canti": "ng serve app --port=4200",
"start:stereocomics": "ng serve stereocomics --port=4300",
"build:shared": "ng build shared-core",
"build:canti": "npm run build:shared && ng build app --configuration=production",
"build:stereocomics": "npm run build:shared && ng build stereocomics --configuration=production"
}
```
---
### Passo 2.6: Gestione di Capacitor (Mobile Apps Separate)
Per compilare le due app per iOS/Android in modo totalmente indipendente:
1. L'app `canti` continuerà ad usare la configurazione di Capacitor a livello di root, oppure verrà spostata in una sottocartella.
2. Inizializziamo Capacitor specificamente per `stereocomics` posizionando un file `capacitor.config.ts` all'interno di `projects/stereocomics/`:
```typescript
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'it.stereocomics.app',
appName: 'Stereocomics',
webDir: '../../dist/stereocomics', // Punterà alla cartella di build di Angular
bundledWebRuntime: false
};
export default config;
```
---
## 3. Vantaggi e Svantaggi dell'Opzione 2
### Vantaggi:
1. **Massima Libertà di Design e UX**: L'applicazione `stereocomics` può avere una struttura di navigazione, pagine e flussi utente completamente diversi da `canti`.
2. **Isolamento del Codice UI**: I cambiamenti estetici o le nuove feature di interfaccia introdotte su Stereocomics non rischiano di rompere l'applicazione Canti originale.
3. **Riutilizzo della Logica di Business**: Tutta la logica complessa dei database locali, del lettore audio e dei parser rimane centralizzata e testata una volta sola nella libreria `@shared/core`.
### Svantaggi:
1. **Sforzo Iniziale Elevato**: Richiede un refactoring iniziale importante per estrarre tutti i servizi e ridefinire i path di importazione in tutto il progetto.
2. **Build più Lente**: La compilazione richiede prima la build delle librerie condivise e poi dell'applicazione specifica.
3. **Gestione delle Dipendenze**: Sebbene condividano lo stesso `package.json`, l'aggiornamento di una libreria esterna (es. Ionic o Angular) deve essere testato su entrambe le applicazioni per evitare regressioni.
---
## 4. Criteri di Accettazione e Verifica (Verification Plan)
### Verifica dello Sviluppo Locale
1. Eseguire `npm run start:canti` -> Verificare il funzionamento completo dell'app principale sulla porta `4200`.
2. Eseguire `npm run start:stereocomics` -> Verificare che la nuova app risponda sulla porta `4300` con il layout personalizzato.
### Verifica della Compilazione Condivisa
1. Apportare una modifica al servizio `CantiService` (es. aggiungere un log).
2. Verificare che la modifica sia visibile ed efficace sia su `canti` che su `stereocomics` dopo aver compilato la libreria condivisa.
+164
View File
@@ -0,0 +1,164 @@
# Guida alla Creazione di "stereocomics" tramite Angular Workspace Monorepo
Questo documento descrive dettagliatamente la strategia dell'**Opzione 2 (Monorepo)**. L'obiettivo è trasformare l'attuale struttura del progetto in un Workspace Angular multi-applicazione, dove le logiche funzionali (servizi, parser, gestione audio) risiedono in una libreria condivisa, mentre le applicazioni `canti` e `stereocomics` rimangono indipendenti per quanto riguarda interfacce grafiche, stili, asset e configurazioni.
---
## Struttura Finale del Workspace Monorepo
Al termine del processo, la struttura delle cartelle del progetto si presenterà così:
```text
canti/ (Root del Workspace)
├── angular.json # Configurazione di build per entrambi i progetti
├── package.json # Dipendenze condivise
├── tsconfig.json # Configurazione TypeScript con path alias per il core
├── src/ # Codice sorgente dell'applicazione originale "canti"
│ ├── app/ # Componenti, pagine e routing specifici di canti
│ └── assets/ # Immagini, loghi e risorse di canti
└── projects/
├── core/ # LIBRERIA CONDIVISA (TypeScript puro)
│ └── src/
│ ├── public-api.ts # Esporta i servizi core
│ └── lib/
│ └── services/ # I servizi estratti (audio, parser, canti, settings, ecc.)
└── stereocomics/ # NUOVA APPLICAZIONE "stereocomics"
├── src/
│ ├── app/ # Pagine, componenti e routing specifici di stereocomics
│ ├── assets/ # Loghi, immagini e splash screen di stereocomics
│ └── theme/ # CSS/SCSS personalizzato (variabili di colore diverse)
└── capacitor.config.ts # Configurazione Capacitor specifica (es. per iOS/Android)
```
---
## Fasi di Implementazione Dettagliate
### Fase 1: Creazione della Libreria Condivisa (`core`)
Il primo passo consiste nel creare un modulo di libreria all'interno del workspace.
1. **Generazione della libreria**:
Utilizzando l'Angular CLI dalla root del progetto:
```bash
ng generate library core --prefix=core
```
Questo comando creerà la cartella `projects/core` e configurerà automaticamente i path alias nel file `tsconfig.json` (es. `"@core/*"` o `"core"`).
2. **Migrazione dei Servizi**:
Sposteremo i file dei servizi core da `src/app/services/` a `projects/core/src/lib/services/`. I file principali da migrare includono:
- [lyrics-parser.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/lyrics-parser.service.ts)
- [audio-engine.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/audio-engine.service.ts)
- [canti.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/canti.service.ts)
- [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
- [settings.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/settings.service.ts)
- [comunita.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/comunita.service.ts)
- E gli altri servizi correlati.
3. **Esportazione delle API**:
Nel file `projects/core/src/public-api.ts`, esporteremo tutti i servizi migrati in modo che siano importabili dalle applicazioni esterne:
```typescript
export * from './lib/services/canti.service';
export * from './lib/services/lyrics-parser.service';
// ... altre esportazioni
```
4. **Compilazione iniziale**:
Si compila la libreria core affinché sia disponibile per i progetti:
```bash
ng build core
```
---
### Fase 2: Adeguamento dell'Applicazione `canti`
Dopo aver spostato i servizi nella libreria condivisa, dobbiamo aggiornare l'applicazione originale affinché consumi la libreria anziché i vecchi file locali.
1. **Aggiornamento degli Import**:
In tutti i componenti e pagine di `canti` (es. `src/app/home/home.page.ts`), modificheremo gli import dei servizi:
*Prima:*
```typescript
import { CantiService } from '../services/canti.service';
```
*Dopo:*
```typescript
import { CantiService } from 'core';
```
2. **Rimozione dei Vecchi Servizi**:
Elimineremo la cartella `src/app/services/` ormai vuota.
3. **Test di Verifica**:
Avvieremo `canti` con `npm run start` (o `ng serve`) per assicurarci che l'applicazione funzioni correttamente importando i servizi dalla libreria.
---
### Fase 3: Generazione dell'Applicazione `stereocomics`
Ora che le fondamenta condivise sono pronte, creiamo la nuova applicazione `stereocomics`.
1. **Generazione**:
Sempre tramite Angular CLI:
```bash
ng generate application stereocomics --style=scss --routing=true
```
Questo configurerà un nuovo blocco chiamato `stereocomics` in `angular.json` e creerà la cartella `projects/stereocomics`.
2. **Integrazione con Ionic**:
Aggiungeremo il supporto a Ionic nella nuova applicazione importando `IonicModule.forRoot()` nel file `projects/stereocomics/src/app/app.module.ts`.
---
### Fase 4: Sviluppo e Personalizzazione di `stereocomics`
A questo punto abbiamo un'applicazione vergine che possiamo strutturare come vogliamo, riutilizzando però i servizi core.
1. **Struttura delle Pagine**:
Possiamo decidere di copiare le pagine esistenti di `canti` (se vogliamo che `stereocomics` parta con lo stesso layout per poi essere modificato) oppure creare pagine del tutto nuove.
Ad esempio, per generare una pagina specifica in stereocomics:
```bash
ng generate page pages/home --project=stereocomics
```
2. **Consumo dei Servizi Shared**:
Nel codice di `stereocomics`, per caricare i dati useremo la libreria condivisa:
```typescript
import { Component, OnInit } from '@angular/core';
import { CantiService } from 'core';
@Component({ ... })
export class HomePage implements OnInit {
constructor(private cantiService: CantiService) {}
ngOnInit() {
// Possiamo accedere a tutte le funzioni storiche
this.cantiService.loadCanti();
}
}
```
3. **Personalizzazione Visiva (Branding)**:
- Modificheremo `projects/stereocomics/src/theme/variables.scss` per definire la palette di colori di `stereocomics` (ad es. tonalità arancioni o viola, differenziandosi dal blu di `canti`).
- Sostituiremo gli asset in `projects/stereocomics/src/assets/` con loghi, icone e immagini dedicati a stereocomics.
---
### Fase 5: Configurazione dei Comandi di Build
Aggiungeremo o modificheremo gli script nel file `package.json` per facilitare lo sviluppo parallelo:
```json
"scripts": {
"start:canti": "ng serve app",
"start:stereocomics": "ng serve stereocomics",
"build:canti": "ng build app --configuration production",
"build:stereocomics": "ng build stereocomics --configuration production",
"watch:core": "ng build core --watch"
}
```
Durante lo sviluppo, se modifichi un servizio core, il comando `watch:core` ricompilerà automaticamente la libreria in background, aggiornando istantaneamente l'app che stai servendo localmente.
---
## Vantaggi di questo Approccio
* **Sorgente di Verità Unica (Single Source of Truth)**: La complessa logica di business (algoritmi di parsing, logica audio per i player, caching, sync) viene scritta, testata e manutenuta in un solo posto (`projects/core`).
* **Autonomia Grafica ed Esperienziale**: `stereocomics` ha le sue pagine HTML e i suoi fogli di stile CSS/SCSS. Può avere una navigazione a tab, mentre `canti` usa un menu laterale (sidemenu), senza alcun conflitto.
* **Semplicità di Aggiornamento delle Dipendenze**: Entrambe le applicazioni utilizzano gli stessi pacchetti npm (configurati nel `package.json` globale nella root), riducendo il disallineamento delle versioni delle librerie terze (es. Capacitor, Ionic, Angular).
@@ -0,0 +1,72 @@
# Piano Operativo: Clonazione e Personalizzazione in "stereocomics"
Questo piano descrive le opzioni e le fasi operative per creare un clone del progetto **canti** denominato **stereocomics**. Il clone condividerà le stesse librerie funzionali di base (servizi, parser, logica audio, ecc.) offrendo al contempo completa libertà di personalizzazione (grafica, asset, configurazioni, ed eventuali pagine specifiche).
---
## Opzioni Architetturali Proposte
Prima di procedere con l'implementazione, è fondamentale scegliere l'approccio strutturale più adatto:
### Opzione 1: White-Label / Multi-Configuration (Consigliata per Manutenibilità)
*Se l'applicazione stereocomics differisce principalmente per branding, colori, logo, configurazioni e alcuni testi, ma condivide la quasi totalità delle pagine e dei flussi.*
- **Come funziona**: Si mantiene un unico codice sorgente. Si usano i file di configurazione ambientale di Angular (`src/environments/`) e file CSS personalizzati per caricare dinamicamente loghi, stili (tramite variabili CSS/SCSS), e comportamenti in base alla build (es. `ng build --configuration=stereocomics`).
- **Pro**: Semplicità assoluta di manutenzione. Qualsiasi bug fix o nuova funzionalità su un servizio o una pagina si riflette istantaneamente su entrambi i brand senza duplicazione di codice.
- **Contro**: Meno flessibilità se le pagine di `stereocomics` dovranno divergere drasticamente a livello di layout HTML o logica di navigazione rispetto a `canti`.
### Opzione 2: Angular Workspace Monorepo (Consigliata per Massima Personalizzazione)
*Se stereocomics deve avere pagine, componenti e flussi di navigazione diversi da canti, pur riutilizzando gli stessi servizi (audio, parser, database local, ecc.).*
- **Come funziona**: Si trasforma il progetto in un workspace Angular multi-applicazione.
1. Si crea una libreria condivisa (es. `projects/shared-core`) dove vengono spostati tutti i servizi di base (`src/app/services/*`).
2. L'applicazione attuale viene configurata come progetto `canti`.
3. Viene generata una nuova applicazione Angular/Ionic nello stesso workspace (`projects/stereocomics`) che importa i servizi da `shared-core` ma ha le sue pagine, i suoi componenti e la sua veste grafica indipendenti.
- **Pro**: Massimo controllo. Ciascuna app ha la sua struttura di pagine, ma condividono al 100% la logica complessa dei servizi.
- **Contro**: Richiede una ristrutturazione iniziale dei path di importazione dei servizi nel progetto attuale.
### Opzione 3: Repository / Cartella Indipendente
*Se si desidera un progetto completamente separato in una nuova cartella `/Users/davidfrassi/SRC/agenti/stereocomics`.*
- **Come funziona**: Si clona il progetto in una nuova cartella e si personalizza in modo indipendente. Per condividere le librerie, si può creare un package locale (`npm link`) o importare i servizi come sottomodulo git.
- **Pro**: Isolamento totale.
- **Contro**: Rischio elevato di divergenza del codice. I bug fix sui servizi in un progetto dovranno essere riportati manualmente o gestiti tramite rilasci di pacchetti.
---
## User Review Required
> [!IMPORTANT]
> Si prega di verificare quale delle tre opzioni si adatta meglio alle esigenze di sviluppo a lungo termine di **stereocomics**.
>
> - Se il clone differisce solo per loghi, colori e piccoli dettagli, l'**Opzione 1 (White-Label)** è la più rapida ed efficiente.
> - Se il clone deve avere un'interfaccia utente o funzionalità molto diverse pur usando la stessa logica di lettura/parsing, l'**Opzione 2 (Monorepo)** è la scelta ideale.
---
## Fasi del Piano Operativo (Esempio basato sull'Opzione 2 - Monorepo)
Se si sceglie l'approccio Monorepo, i passi saranno i seguenti:
### Fase 1: Preparazione e Ristrutturazione (Refactoring dei Servizi)
1. Spostare i servizi core (ad es. [lyrics-parser.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/lyrics-parser.service.ts), [audio-engine.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/audio-engine.service.ts), [canti.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/canti.service.ts)) in una libreria condivisa o in una cartella core dedicata configurata con path alias in `tsconfig.json` (es. `@shared/services`).
2. Aggiornare gli import in tutto il progetto `canti` per utilizzare il nuovo path alias.
### Fase 2: Creazione del Progetto Stereocomics
1. Generare la nuova applicazione all'interno del workspace o duplicare la struttura configurando il nuovo target in [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json).
2. Configurare gli asset (immagini, loghi, splash screen) per `stereocomics` in una cartella dedicata.
3. Creare il file di configurazione specifico per stereocomics (`environment.stereocomics.ts`).
### Fase 3: Personalizzazione e Stile
1. Creare un tema CSS/SCSS personalizzato per `stereocomics` modificando le variabili di colore Ionic/CSS.
2. Sviluppare eventuali componenti o pagine specifiche per `stereocomics`.
### Fase 4: Configurazione della Build e Deploy
1. Configurare gli script npm in `package.json` per avviare e buildare specificamente il nuovo target (es. `npm run start:stereocomics`, `npm run build:stereocomics`).
2. Configurare Capacitor/PWA per il nuovo brand (nuovo package ID, nome dell'app, icone).
---
## Verification Plan
### Manual Verification
- Avvio di `canti` in modalità sviluppo per verificare che il refactoring dei servizi non abbia introdotto regressioni.
- Avvio di `stereocomics` per verificare il caricamento del nuovo tema, logo e impostazioni personalizzate.
- Test delle funzionalità core (riproduzione, parsing testi) in entrambe le applicazioni.
+164
View File
@@ -0,0 +1,164 @@
# Specifiche e Considerazioni di Design: Editor Visuale Intelligente
Questo documento raccoglie l'analisi e le specifiche di design per l'implementazione del sistema di **Scansione Fotocamera & AI** per la digitalizzazione rapida dei canti nella PWA.
L'obiettivo è progettare un'esperienza utente a "sfregamento zero" (zero friction), robusta anche in ambienti ostili (es. chiese senza campo) e capace di gestire le imperfezioni storiche dei libretti parrocchiali.
---
## 1. Architettura della Pipeline Visiva
Il processo si basa su un'interazione fluida tra client (PWA), proxy server ed il motore multimodale di visione artificiale (Google Gemini 2.5 Flash).
```mermaid
graph TD
A[Inquadratura Fotocamera PWA] --> B[Compressione Client Canvas JPEG ~200KB]
B --> C{Connessione Rete?}
C -- Online --> D[Invia POST a /scanSong]
C -- Offline --> E[Salva in IndexedDB localmente]
E --> F[In attesa di Connessione...]
F --> C
D --> G[Chiamata a Gemini 2.5 Flash]
G --> H[Analisi Spaziale ed Estrazione JSON]
H --> I[Popolamento Automatico dell'Editor PWA]
```
---
## 2. Casi d'Uso Avanzati e Soluzioni Specifiche
### A. Gestione Multi-Pagina (Canti Lunghi)
* **Problema**: Molti canti liturgici (es. Salmi o canti d'ingresso lunghi) si sviluppano su due pagine adiacenti o su foglietti fronte-retro separati.
* **Soluzione**: L'interfaccia dell'editor consentirà all'utente di aggiungere più scatti all'interno della stessa sessione di scansione prima dell'invio.
* **Gestione AI**: Le immagini verranno inviate in blocco al modello di visione (sfruttando l'enorme finestra di contesto di Gemini). Il prompt istruirà l'IA a considerare le foto come sequenziali, unendo il testo in un unico flusso coerente senza duplicare il titolo ed ordinando le strofe in modo corretto.
### B. Correzione dei Refusi Storici (Libretti Anni '90)
* **Problema**: I libretti parrocchiali stampati decenni fa o digitati a macchina presentano spesso refusi tipografici, lettere scambiate o scorrettezze sugli accenti (es. `perche'` al posto di `perché`, `piu'` per `più`, `Gesu'` per `Gesù`).
* **Soluzione**: Sfruttando la comprensione semantica del modello di linguaggio (LLM), l'IA non effettua una mera trascrizione pixel-per-pixel, ma analizza il senso delle parole nel contesto dei canti liturgici italiani (molti dei quali fanno già parte della sua conoscenza pregressa).
* **Risultato**: Restituzione di un testo grammaticalmente perfetto e pulito, correggendo automaticamente i vecchi accenti e gli errori di battitura della carta originale.
### C. Traduzione ed Uniformazione degli Accordi
* **Problema**: Gli spartiti o canzonieri cartacei possono utilizzare la notazione internazionale (`C, D, E, F, G, A, B`) anziché la notazione latina standard italiana (`Do, Re, Mi, Fa, Sol, La, Si`).
* **Soluzione**: Nel prompt di sistema, l'IA viene istruita ad uniformare tutti gli accordi alla notazione italiana standard.
* **Esempio**: Se l'utente inquadra un canzonierino scout o internazionale scritto in notazione inglese (es. `[C] [G] [Am]`), il sistema lo tradurrà istantaneamente in `[Do] [Sol] [Lam]` prima di inviarlo all'editor della PWA, garantendo la compatibilità al 100% con il modulo di trasposizione interna dei toni.
---
## 3. Strategia Offline (Resilienza in Chiesa)
Le chiese sono notoriamente ambienti difficili per la connettività di rete (mura in pietra spesse, sotterranei, assenza di ripetitori vicini). Il sistema non deve dipendere da una connessione sempre attiva.
### Flusso Offline con IndexedDB
1. L'utente scatta la foto (o seleziona l'immagine) dall'editor mentre si trova offline.
2. L'app converte l'immagine in `Blob` e la salva localmente all'interno di **IndexedDB** associandola a una coda di scansioni pendenti (`scan-offline-queue`).
3. L'interfaccia mostra un avviso rassicurante:
> 💾 *Fogli acquisiti offline! L'app elaborerà automaticamente la scansione del canto non appena rileverai una connessione internet stabile.*
4. Il `ConnectivityService` della PWA monitora lo stato della rete. Non appena il dispositivo torna online (es. all'uscita dalla chiesa o sotto WiFi):
* L'app estrae l'immagine da IndexedDB.
* Effettua la chiamata POST a `/scanSong`.
* Riceve il JSON tradotto e notifica l'utente (o precompila la scheda dei canti proposti).
---
## 4. Specifiche del Prompt di Sistema (System Prompt)
L'output del backend deve essere rigidamente controllato e tipizzato per consentire al client Angular di digerirlo senza eccezioni di parsing. Il prompt di sistema da implementare sul proxy API sarà il seguente:
```text
Sei un OCR musicale multimodale specializzato in canti liturgici e religiosi cristiani.
Analizza l'immagine fornita (o il set di immagini sequenziali) e restituisci ESCLUSIVAMENTE un oggetto JSON valido. Non aggiungere spiegazioni o markdown.
Schema JSON di risposta:
{
"titolo": "String (titolo del canto)",
"autore": "String o null",
"sezioni": [
{
"tipo": "ritornello" | "strofa",
"numero": Number o null (solo per le strofe, es. 1, 2, 3),
"righe": [
"Testo completo della riga con accordi musicali inseriti in linea tra parentesi quadre prima della sillaba corretta. Es: [Do]Tu sei la mia [Sol]vita"
]
}
]
}
Regole di formattazione:
1. Traduci tutti gli accordi in notazione italiana (Do, Re, Mi, Fa, Sol, La, Si, con 'm' per i minori, '7' per le settime, ecc.).
2. Inserisci gli accordi esattamente nel punto in cui cadono spazialmente sopra le parole dell'immagine.
3. Correggi accenti storici errati (es. trasformando 'perche'' in 'perché') e refusi evidenti.
```
Questo approccio garantisce che la PWA riceva un formato perfettamente strutturato e pronto per essere inserito direttamente nelle righe del database locale.
---
## 5. Approccio 100% Locale e Gratuito (Client-Side)
Se il vincolo fondamentale è **non utilizzare alcun servizio cloud a pagamento o esterno**, possiamo spostare l'intero carico di elaborazione (OCR ed allineamento) **direttamente all'interno del browser dell'utente (client-side)** in modo 100% gratuito ed autonomo.
Ciò è realizzabile integrando due componenti open-source nella PWA:
1. **Tesseract.js** (compilato in WebAssembly per il browser): si occupa di estrarre il testo e, soprattutto, di fornirci i metadati spaziali di ogni singola parola.
2. **Algoritmo Euristico di Allineamento Spaziale**: un algoritmo scritto da noi in TypeScript che mappa la posizione fisica degli accordi rispetto al testo.
### Come funziona l'algoritmo di allineamento locale?
Quando Tesseract.js scansiona l'immagine, non restituisce solo una stringa, ma un array di parole in cui ogni parola contiene il testo ed il suo **Bounding Box (rettangolo di coordinate fisiche sull'immagine: `x0`, `y0`, `x1`, `y1`)**:
```typescript
interface OCRWord {
text: string;
bbox: { x0: number; y0: number; x1: number; y1: number };
}
```
L'algoritmo elabora questi dati in 4 fasi:
#### Fase 1: Raggruppamento in Righe Fisiche
Le parole vengono ordinate per coordinata `y0`. Le parole che hanno valori di `y0` simili (con una tolleranza basata sull'altezza dei caratteri, es. ±15 pixel) vengono raggruppate nella stessa **riga orizzontale**.
#### Fase 2: Classificazione delle Righe (Accordi vs. Testo)
Ogni riga viene analizzata per determinare la sua natura:
* **Riga di Accordi**: Se più del 70% delle parole nella riga corrisponde a pattern di accordi noti (es. parole corte come `Do`, `Sol`, `Re7`, `Lam`, `C`, `G`, `D7`), la riga viene marcata come riga di accordi.
* **Riga di Testo**: In tutti gli altri casi, viene considerata una riga di testo normale.
#### Fase 3: Mappatura e Iniezione degli Accordi
Per ogni **Riga di Accordi**, individuiamo la **Riga di Testo** immediatamente sottostante (quella con coordinata `y` successiva più vicina).
Per ciascun accordo presente nella riga:
1. Calcoliamo la sua coordinata orizzontale centrale: `x_accordo = (bbox.x0 + bbox.x1) / 2`.
2. Scorriamo le parole della riga di testo sottostante per trovare quale parola (o lettera) ha la coordinata `x` più vicina a `x_accordo`.
3. Inseriamo la stringa dell'accordo tra parentesi quadre (es. `[Re]`) esattamente prima di quella parola nel testo finale.
```text
Spaziatura fisica sull'immagine:
Riga 1 (Accordi): [Do] [Sol]
Riga 2 (Testo): Tu sei la mia vita, altro io non ho.
↑ ↑
x=120 x=480
```
#### Fase 4: Segmentazione Strutturale delle Sezioni
L'algoritmo identifica i cambi di sezione (es. il passaggio da Ritornello a Strofa) misurando lo spazio verticale (`gap y`) tra le righe di testo. Un distanziamento verticale significativamente maggiore della media indica una separazione di strofa, consentendo di dividere il canto in blocchi strutturati.
### Pro e Contro dell'Approccio Locale
| Vantaggi | Svantaggi |
| :--- | :--- |
| 🟢 **100% Gratuito**: Nessun costo di licenza o API esterne. | 🔴 **Uso Risorse**: L'elaborazione OCR avviene sul telefono dell'utente (richiede 3-5 secondi). |
| 🟢 **Totale Privacy**: Le immagini non lasciano mai il dispositivo. | 🔴 **Meno tolleranza alle pieghe**: Rispetto a Gemini, un OCR locale soffre di più se la foto è storta o con ombre. |
| 🟢 **Funzionamento Offline**: Funziona anche nel deserto, senza alcuna rete. | 🔴 **Nessuna correzione lessicale**: Non corregge gli errori grammaticali storici del foglio cartaceo. |
---
## 6. Il Compromesso Ideale: Gemini API Free Tier
Esiste una terza via che unisce il meglio dei due mondi (la precisione assoluta dell'AI e il costo zero): **il Free Tier delle API di Google Gemini 2.5 Flash**.
Google offre tramite **Google AI Studio** un piano d'uso completamente gratuito con i seguenti limiti:
* **15 Richieste al minuto (RPM)**
* **1500 Richieste al giorno (RPD)**
Per un'applicazione di canti religiosi dove l'aggiunta di nuovi brani è un'operazione sporadica (eseguita da pochi amministratori/chitarristi solo quando si impara un canto nuovo), **questi limiti gratuiti non verranno mai superati**.
* **Implementazione sicura**: Per evitare di inserire la chiave API gratuita all'interno del codice frontend (dove chiunque potrebbe rubarla), si fa passare la richiesta attraverso una semplice funzione serverless gratuita (es. su *Vercel Free Tier* o *Cloudflare Workers*), che funge da proxy sicuro e vi applica la chiave API prima di chiamare Google.
+84
View File
@@ -0,0 +1,84 @@
# Specifiche Tecniche: Integrazione Servizio `/miei` con PWA Canti
Questo documento descrive le specifiche dell'endpoint `/miei` implementato sul server Node.js e fornisce le linee guida per implementare la sincronizzazione e l'invio delle playlist locali e dei canti personalizzati ("canti miei") dalla PWA.
---
## 🌐 Dettagli dell'Endpoint `/miei`
- **Metodo HTTP**: `POST`
- **URL di Sviluppo**: `http://localhost:3000/miei` (o l'IP/porta del tuo server in produzione)
- **Content-Type**: `application/json`
- **Limite Dimensione Body**: `10MB` (per supportare liste e testi completi di canti)
### 🔑 Identificazione Utente (`uid`)
L'endpoint richiede l'identificativo unico dell'utente (`uid`). Può essere trasmesso in tre modi (in ordine di priorità):
1. **Header HTTP**: `x-user-uid` (scelta consigliata) o `uid`
2. **Query Parameter**: `?uid=<user_uid>`
3. **Wrapper nel Body**: Se il corpo è un oggetto del tipo `{ "uid": "...", "canti": [...] }`
> [!WARNING]
> L'UID dell'utente deve essere una stringa valida e sicura. Sono accettati solo caratteri alfanumerici, trattini e trattini bassi (`^[a-zA-Z0-9_\-]+$`). Qualsiasi tentativo di path traversal (es. contenente `.` o `/`) restituirà un errore `400 Bad Request`.
---
## 📦 Formato dei Dati Richiesto (Payload)
L'endpoint si aspetta di ricevere un **array JSON** contenente la lista dei canti dell'utente (formato analogo a `canti.json`).
### Esempio di Payload (Array di Canti)
```json
[
{
"id_canti": 9001,
"titolo": "Mio Canto Personalizzato 1",
"momenti": ["Ingresso"],
"periodi": ["Lode"],
"testo": "Testo del mio canto personalizzato...\nRit: Alleluia!"
},
{
"id_canti": 9002,
"titolo": "Mio Canto Personalizzato 2",
"momenti": ["Comunione"],
"periodi": [],
"testo": "Testo del secondo canto..."
}
]
```
---
## 🔄 Comportamento del Server
1. **Ricezione e Validazione**:
- Estrae l'UID ed il payload dei canti.
- Valida l'UID (sicurezza path traversal) e verifica che il payload sia un array JSON. In caso contrario, risponde con `400 Bad Request`.
2. **Logica di Backup**:
- Se nella cartella `data/` del server esiste già un file associato all'utente (`data/<uid>.json`), ne crea automaticamente una copia di backup rinominandola o copiandola in `data/<uid>.bak.json`.
3. **Salvataggio**:
- Scrive il nuovo JSON in `data/<uid>.json`.
- Risponde con `200 OK` e un JSON di conferma:
```json
{
"success": true,
"message": "File salvato con successo.",
"uid": "utente_test_123",
"backupCreated": true
}
```
---
## 🛠️ Requisiti per l'Implementazione nella PWA (Client Locale Mac M1)
L'istanza di Antigravity che lavora sul codice della PWA locale dovrà implementare una funzione di sincronizzazione (es. `syncLocalDataToServer`) che esegua i seguenti passi:
1. **Recupero dei dati locali**:
- Estrarre le playlist locali e i canti personalizzati creati dall'utente (solitamente salvati in `localStorage`, `IndexedDB`, o altro database locale).
2. **Normalizzazione e Formattazione**:
- Formattare e unire queste informazioni in un unico array JSON strutturato con i campi chiave per ciascun canto (`id_canti`, `titolo`, `momenti`, `periodi`, `testo`).
3. **Invio al Server**:
- Effettuare una chiamata `fetch` (POST) verso l'endpoint `/miei`.
- Passare l'UID dell'utente autenticato (es. da Firebase Auth o altro sistema di sessione) tramite l'header `x-user-uid`.
4. **Gestione del Feedback**:
- Mostrare all'utente una notifica di successo o gestire eventuali errori di rete o validazione.
+5
View File
@@ -0,0 +1,5 @@
{
"name": "tmp-app",
"integrations": {},
"type": "angular"
}
+44
View File
@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/app'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};
+40
View File
@@ -0,0 +1,40 @@
# Strategia di Suggerimento Canti Liturgici
Questo documento descrive l'approccio tecnico utilizzato per suggerire i canti attinenti alle letture del giorno nella PWA "CantiCristiani".
## 1. Algoritmo di Analisi (Keyword Matching)
Al momento **non viene utilizzata un'Intelligenza Artificiale generativa (LLM)** per due motivi principali:
- **Performance**: L'analisi avviene istantaneamente sul dispositivo dell'utente senza chiamate API esterne lente.
- **Privacy e Offline**: Il sistema può funzionare anche con una connettività limitata una volta scaricato il testo delle letture.
### Fasi dell'Algoritmo:
1. **Keyword Extraction**:
- Il testo di tutte le letture (Prima, Salmo, Seconda, Vangelo) viene normalizzato in minuscolo.
- Vengono rimosse le "stop words" (parole comuni come articoli, preposizioni, congiunzioni).
- Vengono estratte solo le parole con lunghezza superiore a 3 caratteri per evitare rumore.
2. **Ponderazione e Scoring**:
- Ogni canto nel database viene analizzato cercando le keyword estratte.
- **Titolo (Peso 10)**: Se una parola chiave appare nel titolo, il punteggio del canto aumenta drasticamente.
- **Testo/Lyrics (Peso 1)**: Ogni occorrenza di una parola chiave nel testo del canto aggiunge 1 punto al punteggio totale.
3. **Classificazione per Momento Liturgico**:
- I canti vengono raggruppati in base alle categorie liturgiche (Ingresso, Gloria, Offertorio, Comunione, ecc.) definite nel database.
- Il sistema seleziona i migliori 3 canti per ogni categoria per offrire una scelta bilanciata.
## 2. Selezione della Data Predefinita
L'applicazione calcola automaticamente la **prossima domenica** come data di default. Questo perché la maggior parte degli utenti utilizza la funzione per preparare la liturgia festiva imminente.
## 3. Sviluppi Futuri: Ricerca Semantica (Embeddings)
Per superare i limiti del matching testuale, la strategia futura prevede l'integrazione di tecniche di **Semantic Search** che possono essere eseguite direttamente nella PWA:
### Architettura Proposta:
- **Pre-calcolo dei Vettori**: Generazione dei vettori (embeddings) per l'intero database dei canti. Questi dati verrebbero inclusi in una versione estesa del file `canti.json`.
- **Analisi On-Device**: Utilizzo di librerie matematiche leggere (come `similarity-js`) per calcolare la "distanza del coseno" tra il vettore della lettura e quelli dei canti.
- **Vantaggi**: Il sistema identificherebbe correlazioni concettuali (es. "Luce" -> "Mondo", "Pane" -> "Eucarestia") anche in assenza di corrispondenze testuali esatte.
### Implementazione Tecnica:
1. Integrazione di un modello di embedding (es. `all-MiniLM-L6-v2`) via Web Workers per non bloccare l'interfaccia.
2. Memorizzazione locale dei vettori tramite IndexedDB per garantire performance elevate dopo il primo caricamento.
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "prefetch",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**/*",
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "canti-api",
"urls": [
"https://www.canticristiani.it/it/api/canti.json"
],
"cacheConfig": {
"maxSize": 1,
"maxAge": "7d",
"timeout": "10s",
"strategy": "performance"
}
}
]
}
+54
View File
@@ -0,0 +1,54 @@
# Piano di Implementazione: Importazione Playlist e Canti della Comunità
Questo piano descrive i dettagli tecnici per l'aggiunta di una funzione che consente di importare localmente tutte le playlist e i canti personalizzati di una comunità attiva.
## User Review Required
> [!IMPORTANT]
> L'importazione comporterà il salvataggio dei canti personalizzati della comunità direttamente nei "Miei Canti" dell'utente (evitando duplicati) e la creazione di playlist locali. Le playlist locali create includeranno:
> 1. Una playlist principale con il nome della comunità stessa contenente tutti i canti della comunità con le rispettive tonalità, velocità di scorrimento e testi personalizzati/aggiuntivi.
> 2. Tutte le singole scalette/playlist della comunità importate come playlist locali indipendenti e modificabili.
## Proposed Changes
### Servizi e Logica di Business
#### [MODIFY] [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
Aggiungeremo un metodo helper o esporremo le funzioni di salvataggio di massa per le playlist importate, garantendo che le impostazioni dei brani (`songSettings` contenenti tonalità e velocità di scorrimento) siano preservate correttamente.
### Pagina Impostazioni (Settings Page)
#### [MODIFY] [settings.page.html](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.html)
Nel gruppo "Comunità", quando una comunità è attiva, aggiungeremo un nuovo pulsante sotto i dettagli della comunità:
- **Pulsante**: "Importa Playlist e Canti" con icona `cloud-download-outline`.
#### [MODIFY] [settings.page.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.ts)
Implementeremo il metodo `importComunitaPlaylists()` che svolgerà i seguenti passaggi:
1. **Verifica dello Stato**: Si assicura che una comunità sia attiva e che i dati siano caricati.
2. **Importazione dei Canti Personalizzati**:
- Scorrerà i canti di `comunitaService.comunitaCantiPersonali()`.
- Per ciascun canto, verificherà se è già presente nei "Miei Canti" (tramite `myCantiService.myCanti()`).
- Se assente, lo aggiungerà con prefisso ID `my_` ed effettuerà il salvataggio in locale su `MyCantiService`.
3. **Creazione della Playlist della Comunità**:
- Creerà una playlist avente come nome il nome della comunità stessa.
- Popolerà i canti associando sia i canti standard sia i canti personali importati.
- Assocerà le impostazioni di tonalità e velocità di scorrimento da `comunitaCantiSettings()`. Per la velocità, si utilizzerà la formula di conversione standard (es. `Math.round(speed / 100)`).
4. **Importazione delle Scalette (Playlist) Aggiuntive**:
- Per ciascuna scaletta in `comunitaService.comunitaScalette()`, creerà una playlist locale corrispondente.
- Per ciascun canto della scaletta, cercherà se esiste un'impostazione in `comunitaCantiSettings()` per impostare tonalità e velocità specifiche all'interno della playlist.
5. **Salvataggio e Feedback**:
- Salverà tutte le nuove playlist tramite `playlistService.savePlaylist()`.
- Mostrerà un alert di conferma iniziale e un toast di successo finale.
## Verification Plan
### Manual Verification
- Attivare una comunità di prova (es. con codice noto o inserendone uno valido).
- Andare nelle impostazioni e cliccare sul nuovo pulsante "Importa Playlist e Canti".
- Verificare la comparsa dell'alert di conferma.
- Confermare l'importazione.
- Verificare tramite Toast e navigando nella home che:
- Sia presente una playlist locale con il nome della comunità contenente tutti i canti ordinati e configurati.
- Siano presenti le playlist secondarie della comunità convertite in playlist locali.
- Le tonalità modificate e le velocità di scorrimento siano state importate correttamente per ciascun canto all'interno delle playlist.
- I canti personali/aggiuntivi siano stati importati nella sezione "Miei Canti" e siano visualizzabili.
+20266
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
{
"name": "canticristiani",
"version": "0.0.1",
"author": "Ionic Framework",
"homepage": "https://ionicframework.com/",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^20.0.0",
"@angular/cdk": "^20.2.14",
"@angular/common": "^20.0.0",
"@angular/compiler": "^20.0.0",
"@angular/core": "^20.0.0",
"@angular/forms": "^20.0.0",
"@angular/platform-browser": "^20.0.0",
"@angular/platform-browser-dynamic": "^20.0.0",
"@angular/router": "^20.0.0",
"@angular/service-worker": "^20.0.0",
"@capacitor/app": "8.1.0",
"@capacitor/core": "8.3.1",
"@capacitor/haptics": "8.0.2",
"@capacitor/keyboard": "8.0.3",
"@capacitor/preferences": "^8.0.1",
"@capacitor/status-bar": "8.0.2",
"@ionic/angular": "^8.0.0",
"@ionic/storage-angular": "^4.0.0",
"@zxing/browser": "^0.2.0",
"@zxing/library": "^0.23.0",
"@zxing/ngx-scanner": "^21.0.0",
"ionicons": "^7.0.0",
"mammoth": "^1.12.0",
"pdfjs-dist": "^5.7.284",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tesseract.js": "^7.0.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^20.0.0",
"@angular-eslint/builder": "^20.0.0",
"@angular-eslint/eslint-plugin": "^20.0.0",
"@angular-eslint/eslint-plugin-template": "^20.0.0",
"@angular-eslint/schematics": "^20.0.0",
"@angular-eslint/template-parser": "^20.0.0",
"@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^20.0.0",
"@angular/language-service": "^20.0.0",
"@capacitor/cli": "8.3.1",
"@ionic/angular-toolkit": "^12.0.0",
"@types/jasmine": "~5.1.0",
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^9.16.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^48.2.1",
"eslint-plugin-prefer-arrow": "1.2.2",
"jasmine-core": "~5.1.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.0"
},
"description": "An Ionic project"
}
+144
View File
@@ -0,0 +1,144 @@
# Parametri letti da canti.json e da Comunità
Questo documento elenca tutti i parametri e i campi strutturati che vengono letti, elaborati e salvati dall'applicazione a partire dal file globale `canti.json` e dalle API/file di configurazione delle **Comunità**.
---
## 1. Parametri da `canti.json`
Il file `canti.json` rappresenta il database globale dei canti dell'applicazione. Viene scaricato dall'endpoint configurato in `CantiService` (es. `https://www.canticristiani.it/api/canti.json`).
### Struttura Principale del File
Il JSON restituito contiene diversi nodi chiave, ciascuno contenente un array `data`:
```json
{
"canti": { "data": [...] },
"indice_liturgico": { "data": [...] },
"indice_tematico": { "data": [...] },
"tema": { "data": [...] },
"canti_eseguiti": { "data": [...] }
}
```
#### Nodo `canti.data` (Lista dei Canti)
Ciascun elemento rappresenta un canto e viene mappato nell'interfaccia `Canto`:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | Identificativo univoco del canto (usato internamente anche come stringa `id`). |
| `titolo` | `string` | Titolo del canto. |
| `testo` | `string` | Testo del canto (può includere indicazioni di accordi). |
| `accordi` | `string` (opzionale) | Accordi musicali associati al canto. |
| `autore` | `string` (opzionale) | Autore o autori del canto. |
| `link_youtube` | `string` (opzionale) | URL o ID del video di YouTube associato al canto. |
| `data_update` | `string` (opzionale) | Data dell'ultimo aggiornamento (formato `YYYY-MM-DD HH:mm:ss`, formattata a schermo in `DD/MM/YYYY`). |
| `nonValidato` | `boolean` (opzionale) | Indica se il canto è in attesa di validazione. |
| `isPersonal` | `boolean` (opzionale) | Flag locale per identificare se si tratta di un canto personale dell'utente. |
*Nota: Durante l'importazione, viene aggiunto un array `id_momenti: number[]` ricavato dalla tabella pivot `tema.data`.*
#### Nodo `indice_liturgico.data` e `indice_tematico.data` (Indici/Tag)
Mappati nell'interfaccia `Indice`:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_indice_liturgico` / `id_indice_tematico` | `number` | Identificativo dell'indice/momento. |
| `tag_name` | `string` | Nome visualizzato del tag (es. "Ingresso", "Offertorio"). |
| `slug` | `string` | Versione ottimizzata per URL del tag. |
*Nota: A livello applicativo viene aggiunto il campo `type` con valore `'liturgico'` o `'tematico'`.*
#### Nodo `tema.data` (Relazione Pivot Canti-Indici)
Utilizzato per associare a ogni canto i rispettivi momenti liturgici o tematici:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto associato. |
| `id_momento` | `number` | ID del momento liturgico/tematico. |
#### Nodo `canti_eseguiti.data`
Informazioni sull'esecuzione dei canti:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto eseguito. |
| `num` | `number` | Numero di esecuzioni o indicatore di frequenza. |
---
## 2. Parametri dalle Comunità
I dati di una comunità vengono letti in due modi: tramite l'API di produzione (v3) oppure tramite un file JSON statico di fallback.
### Opzione A: API di Produzione (`get_all_app_tables`)
Endpoint: `https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables`
L'API risponde con un oggetto contenente diverse tabelle relazionali:
#### 1. `parrocchia.data` (Dettagli della Comunità)
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_parrocchia` | `number` | ID interno della parrocchia/comunità. |
| `nome` | `string` | Nome della comunità (es. "Parrocchia S. Maria"). |
| `codice` | `string` | Codice alfanumerico della comunità. |
| `mail` | `string` | Email di riferimento della comunità. |
| `guid_parrocchia` | `string` | GUID univoco. |
#### 2. `parrocchia_canti.data` (Associazione Canti-Comunità)
Indica quali canti del database generale appartengono al repertorio della comunità:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_parrocchia` | `number` | ID della parrocchia. |
| `id_canti` | `number` | ID del canto associato. |
| `num_canto` | `number` | Numero progressivo o di classificazione del canto all'interno della comunità. |
#### 3. `canti_settings.data` (Impostazioni di Esecuzione personalizzate)
Configurazioni specifiche per l'esecuzione del canto in comunità:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto. |
| `speed` | `number` | Velocità di scorrimento (auto-scroll) consigliata. |
| `tonalita` | `number` | Semitoni di trasposizione (trasporto tonalità) consigliati. |
#### 4. `canti_personali.data` (Canti personalizzati/inediti della Comunità)
Canti inseriti direttamente dalla comunità e non presenti nel database globale:
| Parametro Originale | Tipo | Descrizione / Mapping Applicativo |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto personale. |
| `titolo` | `string` | Titolo del canto. |
| `accordi` | `string` | Testo con gli accordi del canto. |
| `autore` | `string` | Autore del canto. |
| `link_youtube` | `string` | Link YouTube. |
| `data_update` | `string` | Data dell'ultimo aggiornamento. |
| `stato` | `number` | Se uguale a `10`, il canto viene contrassegnato come `nonValidato = true`. |
*Nota: Vengono impostati automaticamente `isPersonal = true` e `id_momenti = []`.*
#### 5. Scalette della Comunità (`lista_nome` + `lista_esecuzione`)
L'applicazione ricostruisce l'elenco delle scalette (`ComunitaScaletta`):
* **`lista_nome.data`** (Testate delle scalette):
* `id_lista` (`string`/`number`): ID della scaletta.
* `nome` (`string`): Nome della scaletta (es. "Domenica delle Palme").
* `progr` (`string`): Data o stringa di ordinamento (mappata in `date`).
* **`lista_esecuzione.data`** (Canti contenuti nelle scalette):
* `id_lista` (`string`/`number`): Associazione alla scaletta.
* `id_canti` (`number`): ID del canto.
* `progr` (`number`): Ordine progressivo del canto all'interno della scaletta (usato per l'ordinamento).
---
### Opzione B: Fallback Statico (`comunita_[codice].json`)
Se l'API di produzione non è raggiungibile, viene tentato il download di un file statico (es. `comunita_123456.json`) dall'origine del sito.
La struttura attesa per questo file è molto più semplice:
| Parametro JSON | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_comunita` | `string` | Codice identificativo della comunità. |
| `nome_comunita` | `string` | Nome leggibile della comunità. |
| `canti` | `(number \| string)[]` | Array contenente gli ID di tutti i canti associati a questa comunità. |
+159
View File
@@ -0,0 +1,159 @@
# Piano delle Azioni per la Generazione di una Batteria di Test (`.spec.ts`)
Questo documento descrive in dettaglio la strategia, l'analisi dello stato attuale e l'elenco delle azioni sequenziali necessarie per implementare una suite completa di test unitari e di integrazione per tutte le classi (servizi, pagine e componenti) dell'applicazione Angular/Ionic.
---
## 📊 Analisi dello Stato Attuale dei Test
L'applicazione utilizza **Angular v20**, **Ionic v8**, e il framework di test standard **Jasmine** abbinato al test runner **Karma**.
Un'analisi del codice ha rilevato la seguente situazione per ciascun componente chiave:
### 1. Servizi (`src/app/services`)
I servizi gestiscono la logica di business fondamentale (audio, sincronizzazione, storage, stato).
* ❌ `audio-engine.service.ts` — **Nessun test**
* ❌ `canti-letture.service.ts` — **Nessun test**
* ❌ `canti.service.ts` — **Nessun test**
* ❌ `comunita.service.ts` — **Nessun test**
* ❌ `connectivity.service.ts` — **Nessun test**
* ❌ `lyrics-parser.service.ts` — **Nessun test**
* ❌ `media-session.service.ts` — **Nessun test**
* ⚠️ `my-canti.service.ts` — **Spec esistente ma corrotto/obsoleto** (`my-canti.spec.ts` fa riferimento a una classe `MyCanti` che non esiste, la classe reale è `MyCantiService`).
* ❌ `playlist.service.ts` — **Nessun test**
* ❌ `settings.service.ts` — **Nessun test**
* ❌ `stats.service.ts` — **Nessun test**
* ❌ `theme.service.ts` — **Nessun test**
* ❌ `youtube-player.service.ts` — **Nessun test**
### 2. Pagine (`src/app/pages` & `src/app/home`)
Le pagine gestiscono la visualizzazione e l'interazione con l'utente.
* ⚠️ `home.page.ts` — Spec esistente (`home.page.spec.ts`) ma limitato al boilerplate base (verifica solo la creazione).
* ⚠️ `display.page.ts` — Spec esistente (`display.page.spec.ts`) solo boilerplate base.
* ⚠️ `player.page.ts` — Spec esistente (`player.page.spec.ts`) solo boilerplate base.
* ❌ `playlist.page.ts` — **Nessun test**
* ⚠️ `propose-canto.page.ts` — Spec esistente (`propose-canto.page.spec.ts`) solo boilerplate base.
* ❌ `settings.page.ts` — **Nessun test**
### 3. Componenti (`src/app/components`)
* ❌ `qr-scanner/qr-scanner.component.ts` — **Nessun test**
---
## 🛠️ Strategia di Mocking e Test in Angular/Ionic
Per testare efficacemente queste classi in isolamento, definiremo una strategia di mocking standard:
1. **Storage di Ionic (`@ionic/storage-angular`)**:
Molti servizi (`MyCantiService`, `SettingsService`, `ComunitaService`) dipendono dallo Storage. Creeremo un mock per simulare i metodi `get`, `set` e `remove` usando una mappa in memoria.
2. **Controller di Ionic (`ToastController`, `ModalController`, `AlertController`)**:
Utilizzeremo `jasmine.createSpyObj` per simulare la creazione e la presentazione dei componenti UI di Ionic senza istanziarli realmente nel DOM durante i test dei servizi.
3. **Ambiente HTML5 (Audio, Window)**:
Servizi come `AudioEngineService` e `YoutubePlayerService` interagiscono con le API audio del browser e l'oggetto `window`. Useremo dei mock o spy su `Audio`, `window.location`, ecc.
---
## 🚀 Elenco Dettagliato delle Azioni da Compiere
Le azioni sono suddivise in 6 fasi logiche per garantire un approccio sistematico e sicuro.
### Fase 1: Verifica dell'Ambiente e Correzione dei Test Esistenti
Prima di scrivere nuovi test, dobbiamo assicurarci che l'infrastruttura di test esistente sia stabile e funzionante.
- [ ] **Azione 1.1: Esecuzione iniziale dei test**
* Eseguire `npm run test` (o `ng test --watch=false`) per verificare se la suite iniziale compila ed è verde.
- [ ] **Azione 1.2: Correzione di `my-canti.spec.ts`**
* Rinominare il file in `my-canti.service.spec.ts` per uniformità.
* Correggere gli import: importare `MyCantiService` anziché `MyCanti`.
* Configurare il `TestBed` fornendo il mock per `Storage`, `CantiService` e `ToastController`.
* Verificare che il test passi con successo.
- [ ] **Azione 1.3: Aggiornamento dei boilerplate delle pagine esistenti**
* Risolvere eventuali errori nei test pregenerati (`home.page.spec.ts`, `display.page.spec.ts`, `player.page.spec.ts`, `propose-canto.page.spec.ts`) fornendo i moduli minimi (`IonicModule`, `RouterTestingModule`) e i mock dei servizi iniettati nei loro costruttori.
---
### Fase 2: Implementazione dei Test sui Servizi (Core Logico)
I servizi sono la priorità in quanto non hanno dipendenze grafiche complesse e contengono la logica di business principale.
- [ ] **Azione 2.1: Test per `connectivity.service.ts` e `theme.service.ts`**
* *Perché:* Sono i più semplici e privi di dipendenze esterne pesanti.
* *Test:* Cambiamenti di stato della rete online/offline, applicazione corretta delle classi CSS per i temi (scuro/chiaro).
- [ ] **Azione 2.2: Test per `lyrics-parser.service.ts`**
* *Casi di test:* Parsing corretto dei testi dei canti con accordi tra parentesi (es. `[Do]`), gestione delle righe vuote, estrazione del testo pulito, formattazione.
- [ ] **Azione 2.3: Test per `settings.service.ts`**
* *Casi di test:* Caricamento delle impostazioni di default da storage, salvataggio di nuove impostazioni, gestione del cambio di dimensione del testo, export/import delle impostazioni.
- [ ] **Azione 2.4: Test per `stats.service.ts`**
* *Casi di test:* Incremento delle statistiche di visualizzazione di un canto, persistenza su storage, generazione dei report dei canti più cantati.
- [ ] **Azione 2.5: Test per `canti.service.ts` e `canti-letture.service.ts`**
* *Casi di test:* Lettura del database dei canti, filtri di ricerca per titolo/testo/momento liturgico, associazione tra letture del giorno e canti consigliati (sulla base delle strategie liturgiche descritte in `liturgia_strategy.md`).
- [ ] **Azione 2.6: Test per `comunita.service.ts`**
* *Casi di test:* Gestione del codice comunità, sincronizzazione dei canti della comunità tramite chiamate HTTP (mocking di `HttpClient`), salvataggio in storage locale.
- [ ] **Azione 2.7: Test per `playlist.service.ts`**
* *Casi di test:* Creazione, modifica e cancellazione di playlist, riordino dei canti all'interno di una playlist, salvataggio e ripristino da storage.
- [ ] **Azione 2.8: Test per `media-session.service.ts`**
* *Casi di test:* Integrazione con l'API `navigator.mediaSession` del browser, aggiornamento dei metadati audio (titolo, autore, copertina), gestione degli eventi di riproduzione/pausa del sistema operativo.
- [ ] **Azione 2.9: Test per `audio-engine.service.ts` e `youtube-player.service.ts`**
* *Casi di test:* Avvio, pausa, stop e seek delle tracce audio locali e dei video YouTube, gestione degli eventi di fine riproduzione, aggiornamento dello stato tramite Signals/RxJS.
---
### Fase 3: Implementazione dei Test sui Componenti Condivisi
- [ ] **Azione 3.1: Test per `qr-scanner.component.ts`**
* *Casi di test:* Inizializzazione della fotocamera, gestione dei permessi negati, decodifica del codice QR (simulando l'evento del decoder), emissione del codice scansionato tramite `@Output()`, interruzione dello streaming video alla distruzione del componente.
---
### Fase 4: Implementazione e Arricchimento dei Test sulle Pagine (UI & Controller)
Qui testeremo l'integrazione tra i servizi (mockati) e l'interfaccia utente di Ionic.
- [ ] **Azione 4.1: Test approfonditi per `home.page.ts`**
* *Casi di test:* Visualizzazione dell'elenco dei canti, funzionamento della barra di ricerca (filtro istantaneo), apertura dei dettagli del canto, gestione della navigazione verso le altre pagine.
- [ ] **Azione 4.2: Test approfonditi per `player.page.ts`**
* *Casi di test:* Interazione con i pulsanti di play/pause, scorrimento del testo a tempo, trasposizione degli accordi (es. +1 semitono, -1 semitono) con aggiornamento dinamico del testo a schermo.
- [ ] **Azione 4.3: Test approfonditi per `display.page.ts`**
* *Casi di test:* Rendering corretto del testo del canto, applicazione del tema visivo e della dimensione del font dalle impostazioni, gestione del blocco dello spegnimento dello schermo (se implementato).
- [ ] **Azione 4.4: Creazione e test per `playlist.page.ts`**
* *Casi di test:* Visualizzazione dell'elenco delle playlist dell'utente, creazione di una nuova playlist tramite popup, aggiunta di canti, eliminazione di canti con swipe.
- [ ] **Azione 4.5: Test approfonditi per `propose-canto.page.ts`**
* *Casi di test:* Validazione del form di proposta (titolo obbligatorio, testo minimo), generazione della mailto URL corretta con il body in formato JSON, comportamento del pulsante di invio.
- [ ] **Azione 4.6: Creazione e test per `settings.page.ts`**
* *Casi di test:* Toggle del tema scuro (con verifica dell'applicazione al DOM), selezione della dimensione del font, reset dei dati locali con richiesta di conferma tramite `AlertController`.
---
### Fase 5: Raccolta delle Metriche di Coverage e Rifinitura
- [ ] **Azione 5.1: Configurazione del report di coverage**
* Assicurarsi che `karma.conf.js` sia configurato per esportare i dati in formato `lcov` o `html` (tramite `karma-coverage`).
- [ ] **Azione 5.2: Generazione del report**
* Eseguire `ng test --code-coverage --watch=false`.
* Esaminare la cartella `coverage/` generata per individuare eventuali rami logici (branches) o righe non coperte nei servizi core.
- [ ] **Azione 5.3: Incremento mirato del coverage**
* Aggiungere test specifici per coprire i casi limite (*edge cases*) e la gestione degli errori (es. fallimento delle chiamate HTTP, storage corrotto).
---
### Fase 6: Automazione in CI/CD (Opzionale ma Consigliato)
- [ ] **Azione 6.1: Configurazione test headless**
* Configurare Karma per eseguire i test in modalità headless usando `ChromeHeadless` su sistemi di Continuous Integration (es. GitHub Actions).
* Aggiungere uno script npm `test:ci`: `"test:ci": "ng test --watch=false --browsers=ChromeHeadless"`.
---
## 📈 Tabella di Marcia Consigliata
```mermaid
gantt
title Roadmap per la Copertura dei Test
dateFormat YYYY-MM-DD
section Fase 1
Verifica & Fix Esistenti :active, 2026-05-21, 2d
section Fase 2
Test Servizi Core : 2026-05-23, 5d
section Fase 3 & 4
Test Componenti & Pagine : 2026-05-28, 5d
section Fase 5 & 6
Coverage & CI/CD : 2026-06-02, 2d
```
> [!TIP]
> **Consiglio per l'efficienza**: Si raccomanda di iniziare ad implementare i test partendo dai servizi a più basso livello (come `ConnectivityService`, `ThemeService`, `LyricsParserService`) perché sono privi di dipendenze e consentono di stabilire rapidamente dei pattern di test solidi prima di affrontare servizi più complessi o interfacce grafiche.
+19
View File
@@ -0,0 +1,19 @@
<IfModule mod_rewrite.c>
RewriteEngine On
# Se il file o la directory richiesti esistono, li serve direttamente
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
RewriteRule ^ index.html [L]
</IfModule>
<IfModule mod_headers.c>
# Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker
<FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest|version\.json">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires 0
</FilesMatch>
</IfModule>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

+80
View File
@@ -0,0 +1,80 @@
{
"name": "CantiCristiani",
"short_name": "CantiCristiani",
"display": "standalone",
"scope": "/",
"start_url": "/",
"theme_color": "#3880ff",
"background_color": "#ffffff",
"launch_handler": {
"client_mode": "focus-existing"
},
"protocol_handlers": [
{
"protocol": "web+canti",
"url": "/?url=%s"
}
],
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+13
View File
@@ -0,0 +1,13 @@
import urllib.request
import json
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
print("Fetching...")
with urllib.request.urlopen(req) as res:
data = json.loads(res.read().decode("utf-8"))
settings = data.get("canti_settings", {}).get("data", [])
print(f"Total settings: {len(settings)}")
for s in settings[:10]:
print(s)
+154
View File
@@ -0,0 +1,154 @@
import os
import sys
import threading
from ftplib import FTP, error_perm
from concurrent.futures import ThreadPoolExecutor, as_completed
thread_local = threading.local()
def load_env():
env = {}
if os.path.exists('.env'):
with open('.env', 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '=' in line:
key, val = line.split('=', 1)
env[key.strip()] = val.strip()
return env
def get_ftp_connection(ftp_host, ftp_user, ftp_pass):
if not hasattr(thread_local, "ftp") or thread_local.ftp is None:
ftp = FTP(ftp_host, timeout=30)
ftp.login(ftp_user, ftp_pass)
ftp.passive = True
thread_local.ftp = ftp
return thread_local.ftp
def close_thread_connection():
if hasattr(thread_local, "ftp") and thread_local.ftp is not None:
try:
thread_local.ftp.quit()
except:
try:
thread_local.ftp.close()
except:
pass
thread_local.ftp = None
def ensure_remote_dir(ftp, path):
parts = path.replace('\\', '/').strip('/').split('/')
current = ""
for part in parts:
if not part:
continue
current += "/" + part
try:
ftp.mkd(current)
print(f"\nCreated remote dir: {current}")
except error_perm:
# Directory already exists or permission error (which is normal if it exists)
pass
def upload_file_task(local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock):
ftp_path = rel_path.replace('\\', '/')
remote_dir = os.path.dirname(ftp_path)
# Ensure remote directory exists
if remote_dir:
with created_dirs_lock:
if remote_dir not in created_dirs:
try:
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
ensure_remote_dir(ftp, remote_dir)
created_dirs.add(remote_dir)
except Exception as e:
thread_local.ftp = None
try:
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
ensure_remote_dir(ftp, remote_dir)
created_dirs.add(remote_dir)
except Exception as err2:
return False, ftp_path, f"Failed directory creation: {err2}"
# Upload the file
for attempt in range(3):
try:
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
with open(local_path, 'rb') as f:
ftp.storbinary(f"STOR {ftp_path}", f)
return True, ftp_path, None
except Exception as e:
thread_local.ftp = None # Force reconnect on next attempt
if attempt == 2:
return False, ftp_path, str(e)
return False, ftp_path, "Unknown error"
def main():
env = load_env()
ftp_host = "ftp.canticristiani.it"
ftp_user = "canticristiani.it"
ftp_pass = env.get("FTP_PASSWORD")
if not ftp_pass:
print("❌ Error: FTP_PASSWORD not found in .env")
sys.exit(1)
local_dir = "www"
if not os.path.isdir(local_dir):
print(f"❌ Error: Local dir '{local_dir}' not found.")
sys.exit(1)
files_to_upload = []
for root, dirs, files in os.walk(local_dir):
for file in files:
local_path = os.path.join(root, file)
rel_path = os.path.relpath(local_path, local_dir)
files_to_upload.append((local_path, rel_path))
total = len(files_to_upload)
uploaded = 0
errors = []
created_dirs = set()
created_dirs_lock = threading.Lock()
progress_lock = threading.Lock()
# Thread limit: default to 100 as requested
num_threads = int(os.environ.get("FTP_THREADS", env.get("FTP_THREADS", "100")))
print(f"🚀 Starting parallel upload of {total} files using {num_threads} threads...")
def run_task(item):
nonlocal uploaded
local_path, rel_path = item
success, ftp_path, err_msg = upload_file_task(
local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock
)
with progress_lock:
uploaded += 1
print(f"\rUploading [{uploaded}/{total}] {ftp_path}...", end="", flush=True)
if not success:
errors.append((ftp_path, err_msg))
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(run_task, item) for item in files_to_upload]
for future in as_completed(futures):
pass
# Cleanup connections
with ThreadPoolExecutor(max_workers=num_threads) as cleanup_executor:
cleanups = [cleanup_executor.submit(close_thread_connection) for _ in range(num_threads)]
for future in as_completed(cleanups):
pass
if errors:
print(f"\n⚠️ Deploy completed with {len(errors)} errors:")
for path, err in errors:
print(f" {path}: {err}")
sys.exit(1)
else:
print("\n\n✅ Deploy completed successfully with 100% files uploaded without errors!")
if __name__ == '__main__':
main()
+34
View File
@@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
function walkDir(dir, callback) {
fs.readdirSync(dir).forEach(f => {
let dirPath = path.join(dir, f);
let isDirectory = fs.statSync(dirPath).isDirectory();
if (isDirectory) {
walkDir(dirPath, callback);
} else {
callback(dirPath);
}
});
}
const icons = new Set();
walkDir(path.join(__dirname, '../src/app'), (filePath) => {
if (filePath.endsWith('.html') || filePath.endsWith('.ts')) {
const content = fs.readFileSync(filePath, 'utf8');
// Match name="icon-name"
const nameMatches = content.matchAll(/name=["']([a-zA-Z0-9-]+)["']/g);
for (const match of nameMatches) {
icons.add(match[1]);
}
// Match [name]="... ? 'icon-a' : 'icon-b'"
const ternaryMatches = content.matchAll(/'([a-zA-Z0-9-]+-outline|[a-zA-Z0-9-]+-sharp|[a-zA-Z0-9-]+)'/g);
for (const match of ternaryMatches) {
icons.add(match[1]);
}
}
});
console.log(JSON.stringify(Array.from(icons).sort(), null, 2));
+34
View File
@@ -0,0 +1,34 @@
import urllib.request
import json
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
print("Fetching...")
with urllib.request.urlopen(req) as res:
data = json.loads(res.read().decode("utf-8"))
canti_pers = data.get("canti_personali", {}).get("data", [])
states = {}
for cp in canti_pers:
state = cp.get("stato")
states[state] = states.get(state, 0) + 1
print("States found in canti_personali:")
for state, count in states.items():
print(f"- State {state}: {count} songs")
# Let's also check if there are any duplicate IDs between canti and canti_personali
canti_ids = {c["id_canti"] for c in data.get("canti", {}).get("data", [])}
canti_pers_ids = {cp["id_canti"] for cp in canti_pers}
intersection = canti_ids.intersection(canti_pers_ids)
print(f"Number of duplicate song IDs between global catalog and canti_personali: {len(intersection)}")
if len(intersection) > 0:
print("First 5 duplicate IDs:")
print(list(intersection)[:5])
# Find one example of duplicate
dup_id = list(intersection)[0]
global_dup = next(c for c in data["canti"]["data"] if c["id_canti"] == dup_id)
pers_dup = next(cp for cp in canti_pers if cp["id_canti"] == dup_id)
print(f"\nExample Duplicate (ID {dup_id}):")
print(f"Global: Title='{global_dup.get('titolo')}', Stato={global_dup.get('stato')}")
print(f"Personal: Title='{pers_dup.get('titolo')}', Stato={pers_dup.get('stato')}")
+11
View File
@@ -0,0 +1,11 @@
import urllib.request
url = "https://app.canticristiani.it/js/db.js"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req) as res:
content = res.read().decode("utf-8")
lines = content.split("\n")
print("================== js/db.js lines 1260 to 1310 ==================")
for i in range(1259, min(1310, len(lines))):
print(f"{i+1}: {lines[i]}")
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<style>
.chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
margin-right: 0.2em;
border: 1px solid blue;
}
.chord {
font-size: 0.75em;
font-weight: 700;
color: red;
height: 1.2em;
margin-bottom: -0.2em;
border: 1px solid red;
}
.seg-text {
white-space: pre;
border: 1px solid green;
}
</style>
</head>
<body>
<div>
ti amerò come
<span class="chord-segment"><span class="chord">LA</span><span class="seg-text"> </span></span>
<span class="chord-segment"><span class="chord">MI</span><span class="seg-text"></span></span>
</div>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
const textWords = [
{ text: 'come', bbox: { x0: 40, x1: 60 } },
{ text: 'sei', bbox: { x0: 70, x1: 90 } }
];
const chordWords = [
{ text: 'la', bbox: { x0: 65, x1: 75 } },
{ text: 'mi', bbox: { x0: 95, x1: 105 } }
];
function mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
let result = '';
const chordAssignments = new Map<any, any[]>();
chordWords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let closestWord: any = null;
let minDistance = Infinity;
textWords.forEach(textWord => {
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
const dist = Math.abs(chordX - wordXCenter);
if (dist < minDistance) {
minDistance = dist;
closestWord = textWord;
}
});
if (closestWord) {
if (!chordAssignments.has(closestWord)) {
chordAssignments.set(closestWord, []);
}
chordAssignments.get(closestWord)!.push(chord);
}
});
textWords.forEach((textWord, index) => {
const assignedChords = chordAssignments.get(textWord) || [];
// Split chords into before and after the word
const chordsBefore = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 <= textWord.bbox.x1);
const chordsAfter = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 > textWord.bbox.x1);
chordsBefore.sort((a, b) => a.bbox.x0 - b.bbox.x0);
chordsAfter.sort((a, b) => a.bbox.x0 - b.bbox.x0);
chordsBefore.forEach(chord => {
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
result += `[${cleanChord}]`;
});
result += textWord.text;
chordsAfter.forEach(chord => {
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
result += `[${cleanChord}]`;
});
if (index < textWords.length - 1) {
result += ' ';
}
});
return result;
}
console.log(mergeChordsAndLyrics(chordWords, textWords));
+20
View File
@@ -0,0 +1,20 @@
import { LyricsParserService } from './src/app/services/lyrics-parser.service';
const parser = new LyricsParserService();
function testTranspose(chord, semitones) {
const result = parser.transposeChord(chord, semitones);
console.log(`${chord} + ${semitones} -> ${result}`);
}
console.log('Testing Slash Chords:');
testTranspose('DO/SOL', 2);
testTranspose('REm/LA', 1);
testTranspose('MIb/SIb', -1);
testTranspose('FA#/DO#', 2);
console.log('\nTesting Single Chords:');
testTranspose('DO', 2);
testTranspose('REm', 1);
testTranspose('SIb', -1);
+184
View File
@@ -0,0 +1,184 @@
# Strategia SEO e Indicizzazione per la PWA dei Canti
Questo documento definisce la strategia tecnica per consentire l'indicizzazione dei testi dei canti da parte dei motori di ricerca (in particolare Googlebot) e la corretta generazione delle anteprime (social cards) su piattaforme di messaggistica e social media (WhatsApp, Telegram, Facebook, ecc.), mantenendo intatta l'architettura PWA (Progressive Web App) offline-ready.
---
## 1. Il Contesto Tecnologico
L'applicazione è sviluppata con:
- **Angular 20** (Framework Core)
- **Ionic 8** (UI & Routing integration)
- **Angular Service Worker (`@angular/service-worker`)** per le funzionalità offline della PWA.
---
## 2. La Strategia Selezionata: Prerendering / Static Site Generation (SSG)
Dato che i testi dei canti sono **dati statici** (non cambiano in base all'utente connesso e variano molto raramente), la soluzione ottimale è la **Static Site Generation (SSG)**, nota anche come **Prerendering**.
### Come Funziona la Sinergia SSG + PWA
1. **Fase di Build:** Durante la compilazione dell'app (`ng build`), Angular genera un file `index.html` statico e pre-renderizzato per ogni singolo canto (es. `/canti/tu-sei-sorgente/index.html`).
2. **Scansione dello Spider (SEO):** Quando Googlebot o i crawler dei social richiedono l'URL di un canto, il server o la CDN distribuiscono immediatamente il file HTML statico già popolato con il testo del canto e con i meta tag corretti.
3. **Idratazione e PWA (Client):** Quando un utente apre la pagina sul browser, Angular scarica i bundle JavaScript ed esegue l'**hydration** in background. L'applicazione "prende vita" come Single Page Application (SPA), attiva il Service Worker e abilita la navigazione offline e l'installabilità come PWA.
---
## 3. Fasi e Dettagli di Implementazione
### Fase 1: Struttura degli URL e Routing Semantico
Per facilitare la SEO, gli URL devono essere parlanti e privi di simboli di hash (`#`). Attualmente, l'applicazione utilizza già il routing basato su percorsi standard (PathLocationStrategy) in [app-routing.module.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/app-routing.module.ts).
È necessario definire una rotta dedicata per i singoli canti che accetti un parametro semantico (detto *slug* o *alias*), ad esempio:
```typescript
// src/app/app-routing.module.ts
const routes: Routes = [
// ... altre rotte
{
path: 'canti/:slug',
loadChildren: () => import('./pages/canto-detail/canto-detail.module').then(m => m.CantoDetailPageModule)
}
];
```
I collegamenti all'interno dell'applicazione per navigare verso i canti devono utilizzare il tag semantico `<a>` con la direttiva `routerLink`, per consentire a Googlebot di scoprire autonomamente tutte le pagine:
```html
<!-- EVITARE: pulsanti generici con eventi click gestiti in JS -->
<ion-item (click)="navigaAlCanto(canto.slug)">...</ion-item>
<!-- CONSIGLIATO: vero tag link HTML -->
<a [routerLink]="['/canti', canto.slug]" class="canto-link">
<ion-label>{{ canto.titolo }}</ion-label>
</a>
```
---
### Fase 2: Gestione dei Meta Tag Dinamici
Ogni canto deve avere un titolo e una descrizione univoci e ottimizzati per la SEO. In Angular si utilizzano i servizi `Title` e `Meta` di `@angular/platform-browser` per aggiornare i metadati all'inizializzazione del componente:
```typescript
// src/app/pages/canto-detail/canto-detail.page.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Title, Meta } from '@angular/platform-browser';
import { CantiService } from '../../services/canti.service';
@Component({
selector: 'app-canto-detail',
templateUrl: './canto-detail.page.html',
styleUrls: ['./canto-detail.page.scss'],
})
export class CantoDetailPage implements OnInit {
canto: any;
constructor(
private route: ActivatedRoute,
private cantiService: CantiService,
private titleService: Title,
private metaService: Meta
) {}
ngOnInit() {
const slug = this.route.snapshot.paramMap.get('slug');
if (slug) {
this.canto = this.cantiService.getCantoBySlug(slug);
this.updateSEOMetadata();
}
}
updateSEOMetadata() {
const titoloCompleto = `${this.canto.titolo} - Canti Cristiani`;
const descrizione = `Testo e accordi del canto "${this.canto.titolo}". ${this.canto.testo.substring(0, 150)}...`;
// Imposta il titolo della pagina
this.titleService.setTitle(titoloCompleto);
// Imposta i meta tag standard per la SEO
this.metaService.updateTag({ name: 'description', content: descrizione });
// Imposta i tag OpenGraph per la condivisione sui social (Facebook, WhatsApp, Telegram)
this.metaService.updateTag({ property: 'og:title', content: titoloCompleto });
this.metaService.updateTag({ property: 'og:description', content: descrizione });
this.metaService.updateTag({ property: 'og:type', content: 'article' });
this.metaService.updateTag({ property: 'og:url', content: `https://canti.cristiani.it/canti/${this.canto.slug}` });
// Se c'è un'immagine associata o una copertina di default
this.metaService.updateTag({ property: 'og:image', content: 'https://canti.cristiani.it/assets/og-cover.png' });
}
}
```
---
### Fase 3: Configurazione del Prerendering (SSG) in Angular 20
In Angular 20, l'abilitazione del server-side rendering e del prerendering statico durante la compilazione avviene aggiungendo il pacchetto SSR ufficiale:
```bash
ng add @angular/ssr
```
Questo comando configura automaticamente l'applicazione modificando `angular.json` e creando i file necessari per la compilazione lato server.
#### Configurazione delle rotte da pre-renderizzare
Poiché l'elenco dei canti è dinamico (es. risiede in file JSON o database), dobbiamo indicare ad Angular quali rotte generare staticamente durante il comando `ng build`.
Si crea un file di configurazione per definire le rotte o si utilizza uno script per estrarle dinamicamente:
1. **Creare un file delle rotte statiche** `prerender-routes.txt`:
```txt
/home
/settings
/canti/tu-sei-sorgente
/canti/re-dei-re
/canti/lodi-al-altissimo
```
2. **Automatizzare la generazione di questo file** inserendo uno script (es. `generate-routes.js`) da eseguire prima della build che legge l'elenco dei canti dal file JSON locale e scrive l'elenco dei percorsi in `prerender-routes.txt`.
3. **Configurare `angular.json`** per consumare questo file:
```json
"prerender": {
"discoverRoutes": false,
"routesFile": "prerender-routes.txt"
}
```
Al termine della build (`npm run build`), nella cartella di distribuzione (es. `dist/canticristiani/browser`) verranno generate cartelle fisiche con i file `index.html` pronti all'uso per ciascuna rotta definita.
---
### Fase 4: Sitemap.xml e Robots.txt
Per garantire che Googlebot trovi tempestivamente tutti i canti, è fondamentale generare un file `sitemap.xml` da posizionare nella radice del server web.
#### Esempio di `sitemap.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://canti.cristiani.it/home</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<!-- Generato dinamicamente per ogni canto -->
<url>
<loc>https://canti.cristiani.it/canti/tu-sei-sorgente</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
```
#### Esempio di `robots.txt`:
```txt
User-agent: *
Allow: /
Sitemap: https://canti.cristiani.it/sitemap.xml
```
---
## 4. Vantaggi e Risultati Attesi
- **Indicizzazione immediata:** Googlebot indicizzerà i testi dei canti all'istante, consentendo agli utenti di trovare la PWA cercando frammenti di testo o il titolo del canto direttamente su Google.
- **Anteprime nei Social Perfette:** La condivisione dei link sui canali di comunicazione mostrerà anteprime ricche (titolo corretto, frammento del testo del canto e logo dell'app).
- **Integrità PWA:** L'utente beneficerà di un caricamento iniziale ultra-veloce (grazie all'HTML pre-renderizzato) seguito dall'installazione offline e dall'esperienza fluida tipica dell'applicazione mobile.
+38
View File
@@ -0,0 +1,38 @@
import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: 'home',
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
},
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'player',
loadChildren: () => import('./pages/player/player.module').then( m => m.PlayerPageModule)
},
{
path: 'display',
loadChildren: () => import('./pages/display/display.module').then( m => m.DisplayPageModule)
},
{
path: 'settings',
loadChildren: () => import('./pages/settings/settings.module').then( m => m.SettingsPageModule)
},
{
path: 'propose-canto',
loadComponent: () => import('./pages/propose-canto/propose-canto.page').then( m => m.ProposeCantoPage)
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
],
exports: [RouterModule]
})
export class AppRoutingModule { }
+183
View File
@@ -0,0 +1,183 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
<div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div>
<!-- PWA Redirect Overlay -->
<div *ngIf="showRedirectOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: 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: 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>
<!-- Se la PWA è rilevata come installata -->
<ng-container *ngIf="isPwaInstalled()">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; color: #ffffff; -webkit-font-smoothing: antialiased;">Applicazione Installata</h2>
<div style="font-size: 1.05rem; color: rgba(255, 255, 255, 0.9); line-height: 1.6; -webkit-font-smoothing: antialiased; text-align: center; padding: 0 10px; margin-bottom: 25px;">
La app risulta già installata sul dispositivo, chiudi il browser ed usa quella oppure disinstallala se preferisci utilizzarla da qui.
</div>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="stayInBrowserForceUninstallCheck()"
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 (L'ho disinstallata)
</button>
</div>
</ng-container>
<!-- Se l'applicazione NON è installata (flusso normale di redirect / installazione guidata) -->
<ng-container *ngIf="!isPwaInstalled()">
<!-- 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>
<div style="margin-top: 15px; font-size: 0.85rem; color: rgba(255, 255, 255, 0.5); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 0 10px; text-align: center;">
Per evitare problemi di cache, la navigazione da browser è disattivata se la PWA è installata.<br>
Se preferisci usare il browser, disinstalla l'app dal dispositivo.
</div>
</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>
</ng-container>
</div>
</div>
<!-- PWA Install Overlay -->
<div *ngIf="showInstallOverlay()"
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: 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>
View File
+21
View File
@@ -0,0 +1,21 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
+756
View File
@@ -0,0 +1,756 @@
import { Component, inject, NgZone, 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, NavigationStart } from '@angular/router';
import { ToastController, Platform, AlertController, NavController } from '@ionic/angular';
import { Location } from '@angular/common';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators';
import { App } from '@capacitor/app';
import { addIcons } from 'ionicons';
import {
add,
addCircleOutline,
addOutline,
analyticsOutline,
arrowUpCircle,
bookOutline,
calendarOutline,
cameraOutline,
cameraReverseOutline,
carOutline,
chevronBack,
chevronBackOutline,
chevronDown,
chevronDownOutline,
chevronForward,
chevronForwardOutline,
chevronUp,
chevronUpOutline,
closeCircle,
closeCircleOutline,
closeOutline,
cloudDownloadOutline,
cloudOfflineOutline,
cloudUploadOutline,
contrastOutline,
copyOutline,
createOutline,
documentAttachOutline,
documentTextOutline,
downloadOutline,
eyeOutline,
informationCircleOutline,
keypadOutline,
listOutline,
logoApple,
logoYoutube,
mic,
micOutline,
musicalNote,
musicalNotesOutline,
pause,
pauseSharp,
peopleOutline,
personOutline,
phoneLandscapeOutline,
play,
playForwardOutline,
playSharp,
playSkipBackSharp,
playSkipForwardSharp,
pricetagsOutline,
qrCodeOutline,
refreshOutline,
remove,
removeCircleOutline,
removeOutline,
reorderTwoOutline,
saveOutline,
scanOutline,
searchOutline,
settingsOutline,
shareOutline,
shareSocialOutline,
sparklesOutline,
statsChartOutline,
swapVerticalOutline,
trashOutline,
arrowUndoOutline,
videocam,
videocamOffOutline
} from 'ionicons/icons';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
standalone: false,
})
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 platform = inject(Platform);
private router = inject(Router);
private route = inject(ActivatedRoute);
private location = inject(Location);
private navCtrl = inject(NavController);
private toastCtrl = inject(ToastController);
private alertCtrl = inject(AlertController);
private swUpdate = inject(SwUpdate);
private ngZone = inject(NgZone);
public showRedirectOverlay = signal<boolean>(false);
public showInstallOverlay = signal<boolean>(false);
public isInstalling = signal<boolean>(false);
public isRedirecting = signal<boolean>(false);
public isPwaInstalled = signal<boolean>(false);
public redirectFailed = signal<boolean>(false);
public protocolLink = '';
constructor() {
// Gli aggiornamenti automatici e periodici sono stati rimossi.
// L'aggiornamento viene gestito esclusivamente in modo manuale
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
addIcons({
add,
'add-circle-outline': addCircleOutline,
'add-outline': addOutline,
'analytics-outline': analyticsOutline,
'arrow-up-circle': arrowUpCircle,
'book-outline': bookOutline,
'calendar-outline': calendarOutline,
'camera-outline': cameraOutline,
'camera-reverse-outline': cameraReverseOutline,
'car-outline': carOutline,
'chevron-back': chevronBack,
'chevron-back-outline': chevronBackOutline,
'chevron-down': chevronDown,
'chevron-down-outline': chevronDownOutline,
'chevron-forward': chevronForward,
'chevron-forward-outline': chevronForwardOutline,
'chevron-up': chevronUp,
'chevron-up-outline': chevronUpOutline,
'close-circle': closeCircle,
'close-circle-outline': closeCircleOutline,
'close-outline': closeOutline,
'cloud-download-outline': cloudDownloadOutline,
'cloud-offline-outline': cloudOfflineOutline,
'cloud-upload-outline': cloudUploadOutline,
'contrast-outline': contrastOutline,
'copy-outline': copyOutline,
'create-outline': createOutline,
'document-attach-outline': documentAttachOutline,
'document-text-outline': documentTextOutline,
'download-outline': downloadOutline,
'eye-outline': eyeOutline,
'information-circle-outline': informationCircleOutline,
'keypad-outline': keypadOutline,
'list-outline': listOutline,
'logo-apple': logoApple,
'logo-youtube': logoYoutube,
mic,
'mic-outline': micOutline,
'musical-note': musicalNote,
'musical-notes-outline': musicalNotesOutline,
pause,
'pause-sharp': pauseSharp,
'people-outline': peopleOutline,
'person-outline': personOutline,
'phone-landscape-outline': phoneLandscapeOutline,
play,
'play-forward-outline': playForwardOutline,
'play-sharp': playSharp,
'play-skip-back-sharp': playSkipBackSharp,
'play-skip-forward-sharp': playSkipForwardSharp,
'pricetags-outline': pricetagsOutline,
'qr-code-outline': qrCodeOutline,
'refresh-outline': refreshOutline,
remove,
'remove-circle-outline': removeCircleOutline,
'remove-outline': removeOutline,
'reorder-two-outline': reorderTwoOutline,
'save-outline': saveOutline,
'scan-outline': scanOutline,
'search-outline': searchOutline,
'settings-outline': settingsOutline,
'share-outline': shareOutline,
'share-social-outline': shareSocialOutline,
'sparkles-outline': sparklesOutline,
'stats-chart-outline': statsChartOutline,
'swap-vertical-outline': swapVerticalOutline,
'trash-outline': trashOutline,
'arrow-undo-outline': arrowUndoOutline,
videocam,
'videocam-off-outline': videocamOffOutline
});
effect(() => {
this.checkLoaderDismissal();
});
}
checkLoaderDismissal() {
const ready = this.settingsService.isVersionCheckComplete() && this.cantiService.firstLoadCompleted();
if (!ready) {
return;
}
// Se stiamo attivamente installando o reindirizzando, NON nascondiamo il loader
if (this.isInstalling() || this.isRedirecting()) {
return;
}
// Altrimenti, nascondiamo il loader per far entrare l'utente
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
}
async ngOnInit() {
// Gestione del tasto back (PWA/Browser e Nativo/Hardware) secondo le 3 specifiche:
// 1- Se siamo su un canto (/player o /display), premendo back andiamo sempre sulla home.
// 2- Se siamo sulla home (/home o /), premendo back l'app deve uscire.
// 3- Per tutto il resto, segue la logica standard andando alla pagina precedente nello storico.
// Gestione popstate (tasto indietro browser / gesture PWA)
this.router.events.pipe(
filter((e): e is NavigationStart => e instanceof NavigationStart),
filter(e => e.navigationTrigger === 'popstate')
).subscribe(() => {
const currentPath = this.router.url.split('?')[0];
if (currentPath === '/home' || currentPath === '/') {
// Spec 2: Sulla home, usciamo dall'app
this.router.navigate(['/home'], { replaceUrl: true });
this.exitApp();
} else if (currentPath === '/player' || currentPath === '/display') {
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
}
// Spec 3: Per il resto (es. /settings), popstate prosegue normalmente verso la pagina precedente
});
// Gestione tasto indietro hardware (es. Android / Capacitor / PWA)
this.platform.backButton.subscribeWithPriority(10, async () => {
const currentPath = this.router.url.split('?')[0];
if (currentPath === '/home' || currentPath === '/') {
// Spec 2: Sulla home, usciamo dall'app
this.exitApp();
} else if (currentPath === '/player' || currentPath === '/display') {
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
} else {
// Spec 3: Per il resto, logica standard (pagina precedente)
if (window.history.length > 1) {
this.location.back();
} else {
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
}
}
});
// 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);
// Handle PWA Launch Queue if supported (focus-existing launch behavior)
if ('launchQueue' in window) {
(window as any).launchQueue.setConsumer((launchParams: any) => {
if (launchParams.targetURL) {
this.handleLaunchUrl(launchParams.targetURL);
}
});
}
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);
}
}
});
window.addEventListener('appinstalled', () => {
console.log('[AppComponent] PWA appinstalled event caught.');
localStorage.setItem('pwa-installed', 'true');
this.isPwaInstalled.set(true);
this.showInstallOverlay.set(false);
this.showRedirectOverlay.set(false);
// Se la finestra è GIÀ stata trasformata in PWA standalone (es. Desktop Mac/Windows)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) {
this.isInstalling.set(false);
this.isRedirecting.set(false);
this.checkLoaderDismissal();
return;
}
// Su Android, l'evento 'appinstalled' scatta in 2-3 secondi, MA l'OS impiega 10-12s
// per pacchettizzare e registrare il WebAPK nella schermata Home.
// Calibriamo la progress bar spalmata su ~11 secondi reali prima del reindirizzamento.
this.isInstalling.set(true);
this.isRedirecting.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Installazione applicazione',
phase: 'Fase: Registrazione',
desc: 'Generazione e registrazione dell\'applicazione sul dispositivo in corso...',
percent: 15
});
}
const startTime = Date.now();
const TARGET_DURATION_MS = 11000; // 11 secondi reali per completare l'installazione WebAPK
const timer = setInterval(() => {
const elapsed = Date.now() - startTime;
let pct = Math.min(100, Math.round(15 + (elapsed / TARGET_DURATION_MS) * 85));
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: pct });
}
if (pct >= 100) {
clearInterval(timer);
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Chiudi il browser',
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('[AppComponent] Running on localhost - skipping protocol link redirect on appinstalled.');
this.isRedirecting.set(false);
this.checkLoaderDismissal();
return;
}
setTimeout(() => {
window.location.href = this.protocolLink;
}, 1000);
}
}, 250);
});
this.checkAndRedirectToPwa();
}
handleLaunchUrl(urlStr: string) {
try {
const urlObj = new URL(urlStr);
// 1. Check if it's a protocol link inside query params (e.g. /?url=web+canti://...)
const protocolUrl = urlObj.searchParams.get('url');
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
const innerUrlObj = new URL(cleanUrl);
let targetPath = innerUrlObj.pathname;
if (targetPath === '/open' || targetPath === '//open') {
targetPath = '/';
} else if (targetPath.startsWith('/open/')) {
targetPath = targetPath.substring(5);
}
const queryParams: any = {};
innerUrlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
console.log('[AppComponent] Launch queue routing (protocol) to:', targetPath, queryParams);
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
return;
}
// 2. Otherwise, route directly to the pathname and query params of the URL
let targetPath = urlObj.pathname;
const queryParams: any = {};
urlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
console.log('[AppComponent] Launch queue routing (direct) to:', targetPath, queryParams);
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
} catch (e) {
console.error('Failed to parse launch url:', urlStr, e);
}
}
async checkVersionSync(): Promise<boolean> {
// Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
// Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
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}. Avvio aggiornamento automatico...`);
// Scarica e attiva il nuovo Service Worker se abilitato
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 automatica...');
await Promise.race([
this.swUpdate.activateUpdate(),
new Promise<void>((resolve) => setTimeout(resolve, 5000))
]);
}
} catch (e) {
console.warn('[PWA-Update] SwUpdate durante mismatch fallito:', e);
}
}
// Forziamo il controllo di aggiornamento della registrazione Service Worker
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);
}
}
// Deregistra i vecchi Service Worker per applicare la nuova versione pulita
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
}
// Pulisci le cache del browser
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
// Ricarica la pagina in modo trasparente
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.');
return false;
}
}
} catch (e) {
console.warn('[PWA-Update] version.json check fallito:', e);
}
return false;
}
async checkAndRedirectToPwa() {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('[AppComponent] Running on localhost - skipping PWA redirect and install overlays.');
this.checkLoaderDismissal();
return;
}
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) {
localStorage.setItem('pwa-installed', 'true');
this.checkLoaderDismissal();
return;
}
const search = window.location.search;
const path = window.location.pathname;
this.protocolLink = `web+canti://open${path}${search}`;
let isInstalled = false;
if ('getInstalledRelatedApps' in navigator) {
try {
const relatedApps = await (navigator as any).getInstalledRelatedApps();
isInstalled = relatedApps.length > 0;
} catch (e) {
console.warn('Failed to check installed apps:', e);
}
} else {
isInstalled = localStorage.getItem('pwa-installed') === 'true';
}
// Se prima era salvata come installata ma l'utente riceve il prima possibile un beforeinstallprompt,
// significa che l'app è stata disinstallata!
if (this.settingsService.deferredPrompt() || this.settingsService.showInstallButton()) {
isInstalled = false;
localStorage.setItem('pwa-installed', 'false');
}
this.isPwaInstalled.set(isInstalled);
if (isInstalled) {
if (sessionStorage.getItem('skip-pwa-redirect') === 'true') {
this.showRedirectOverlay.set(false);
this.checkLoaderDismissal();
return;
}
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, mostriamo lo stato fallito per aprire manualmente o indicare la disinstallazione
setTimeout(() => {
if (this.showRedirectOverlay()) {
this.redirectFailed.set(true);
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);
} else {
this.checkLoaderDismissal();
}
}
}
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);
this.checkLoaderDismissal();
}
stayInBrowserForceUninstallCheck() {
localStorage.setItem('pwa-installed', 'false');
this.isPwaInstalled.set(false);
this.stayInBrowser();
}
closeInstallOverlay() {
sessionStorage.setItem('skip-pwa-install', 'true');
this.showInstallOverlay.set(false);
this.checkLoaderDismissal();
}
async triggerInstall() {
this.isInstalling.set(true);
this.showInstallOverlay.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Installazione in corso...',
desc: 'Completa l\'installazione tramite la finestra del browser.'
});
}
const outcome = await this.settingsService.installPwa();
if (outcome === 'accepted') {
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Apertura Applicazione...',
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
} else {
this.isInstalling.set(false);
this.isRedirecting.set(false);
this.showInstallOverlay.set(true);
this.checkLoaderDismissal();
}
}
async triggerInstallFromRedirect() {
this.isInstalling.set(true);
this.showRedirectOverlay.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Installazione in corso...',
desc: 'Completa l\'installazione tramite la finestra del browser.'
});
}
const outcome = await this.settingsService.installPwa();
if (outcome === 'accepted') {
sessionStorage.setItem('skip-pwa-redirect', 'true');
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Apertura Applicazione...',
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
} else {
this.isInstalling.set(false);
this.isRedirecting.set(false);
this.showRedirectOverlay.set(true);
this.checkLoaderDismissal();
}
}
private exitApp() {
try {
App.exitApp();
} catch (e) {}
try {
if ((navigator as any)?.app?.exitApp) {
(navigator as any).app.exitApp();
}
} catch (e) {}
try {
window.close();
} catch (e) {}
}
}
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 });
}
}
};
}
+34
View File
@@ -0,0 +1,34 @@
import { NgModule, isDevMode } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { IonicStorageModule } from '@ionic/storage-angular';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
IonicModule.forRoot(),
AppRoutingModule,
HttpClientModule,
IonicStorageModule.forRoot(),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as possible
registrationStrategy: 'registerImmediately'
})
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{ provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true }
],
bootstrap: [AppComponent],
})
export class AppModule {}
@@ -0,0 +1,41 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">QR Code di Ripristino</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="bg-gradient ion-padding">
<div class="qr-container">
<p class="description outfit-font">
Salva questo QR Code (fai uno screenshot o scaricalo) per ripristinare il tuo account e le tue comunità se cambi dispositivo o reinstalli l'applicazione.
</p>
<div class="qr-card glass">
<div class="qr-wrapper" *ngIf="qrCodeUrl">
<img [src]="qrCodeUrl" alt="QR Code di Ripristino" class="qr-image" />
</div>
<div class="uuid-box">
<span class="uuid-label outfit-font">Codice Identificativo:</span>
<span class="uuid-text select-all outfit-font">{{ userUuid }}</span>
</div>
</div>
<div class="actions-wrapper">
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
<ion-icon name="copy-outline" slot="start"></ion-icon>
Copia Codice Testuale
</ion-button>
<a *ngIf="qrCodeUrl" [href]="qrCodeUrl" download="ripristino-identita-canti.png" class="download-btn outfit-font">
<ion-icon name="download-outline"></ion-icon>
Scarica Immagine QR
</a>
</div>
</div>
</ion-content>
@@ -0,0 +1,121 @@
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 8px;
text-align: center;
min-height: 100%;
}
.description {
font-size: 0.95rem;
line-height: 1.5;
color: var(--ion-text-color);
opacity: 0.9;
margin-bottom: 24px;
max-width: 320px;
}
.qr-card {
padding: 24px;
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 24px;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
}
.qr-wrapper {
background: white;
padding: 12px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 220px;
height: 220px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.uuid-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.uuid-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ion-color-secondary);
font-weight: 600;
}
.uuid-text {
font-size: 0.8rem;
color: var(--ion-text-color);
word-break: break-all;
opacity: 0.85;
font-family: monospace;
user-select: all;
background: rgba(var(--ion-text-color-rgb, 255, 255, 255), 0.04);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.actions-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 340px;
}
.action-btn {
margin: 0;
--border-radius: 14px;
--box-shadow: 0 4px 16px rgba(var(--ion-color-secondary-rgb), 0.2);
font-weight: 600;
height: 48px;
}
.download-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
background: rgba(255, 255, 255, 0.06);
color: var(--ion-text-color);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 12px;
border-radius: 14px;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s ease;
ion-icon {
font-size: 1.2rem;
color: var(--ion-color-secondary);
}
&:active {
background: rgba(255, 255, 255, 0.12);
}
}
@@ -0,0 +1,58 @@
import { Component, OnInit, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-identity-qr-modal',
templateUrl: './identity-qr-modal.component.html',
styleUrls: ['./identity-qr-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule]
})
export class IdentityQrModalComponent implements OnInit {
private modalCtrl = inject(ModalController);
private toastCtrl = inject(ToastController);
@Input() userUuid!: string;
public qrCodeUrl: string = '';
ngOnInit() {
this.generateQr();
}
async generateQr() {
try {
this.qrCodeUrl = await QRCode.toDataURL(this.userUuid, {
errorCorrectionLevel: 'H',
margin: 2,
width: 400,
color: {
dark: '#1e293b', // Slate 800 for premium dark aesthetic contrast
light: '#ffffff'
}
});
} catch (err) {
console.error('Failed to generate QR Code:', err);
}
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.userUuid);
const toast = await this.toastCtrl.create({
message: 'Codice copiato negli appunti!',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
} catch (err) {
console.error('Failed to copy text:', err);
}
}
dismiss() {
this.modalCtrl.dismiss();
}
}
@@ -0,0 +1,44 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">Scansiona QR</ion-title>
<ion-buttons slot="end">
<ion-button (click)="cancel()" color="secondary" class="outfit-font">Chiudi</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-no-padding">
<div class="scanner-container">
<zxing-scanner
[formats]="allowedFormats"
[device]="currentDevice"
[autostart]="true"
[tryHarder]="false"
(camerasFound)="onCamerasFound($event)"
(scanSuccess)="onCodeResult($event)"
(scanError)="onScanError($event)"
(scanFailure)="onScanFailure($event)">
</zxing-scanner>
<div class="scan-overlay">
<div class="scan-frame"></div>
<p class="scan-text">Inquadra il QR Code della tua parrocchia</p>
</div>
<!-- Pulsante premium per cambiare fotocamera -->
<div class="camera-toggle-container" *ngIf="availableDevices.length > 1">
<button (click)="toggleCamera()" class="camera-toggle-btn">
<ion-icon name="camera-reverse-outline"></ion-icon>
<span>Cambia fotocamera</span>
</button>
</div>
<!-- Messaggio di aiuto per utenti iPad in modalità desktop -->
<div class="ipad-warning-container" *ngIf="showIpadWarning">
<ion-icon name="information-circle-outline"></ion-icon>
<p>
Su iPad, se non vedi lo switch fotocamera, tocca l'icona <strong>"aA"</strong> in alto nella barra di Safari e seleziona <strong>"Richiedi sito mobile"</strong>.
</p>
</div>
</div>
</ion-content>
@@ -0,0 +1,148 @@
.scanner-container {
position: relative;
width: 100%;
height: 100%;
background: black;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
zxing-scanner {
width: 100%;
height: 100%;
}
.scan-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.4);
pointer-events: none;
.scan-frame {
width: 250px;
height: 250px;
border: 2px solid white;
border-radius: 20px;
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.5);
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--ion-color-secondary);
box-shadow: 0 0 10px var(--ion-color-secondary);
animation: scan 2s linear infinite;
}
}
.scan-text {
color: white;
margin-top: 24px;
font-weight: 500;
text-align: center;
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 {
0% { top: 0; }
100% { top: 100%; }
}
@@ -0,0 +1,76 @@
import { Component, inject } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { ZXingScannerModule } from '@zxing/ngx-scanner';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { BarcodeFormat } from '@zxing/library';
@Component({
selector: 'app-qr-scanner',
templateUrl: './qr-scanner.component.html',
styleUrls: ['./qr-scanner.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, ZXingScannerModule]
})
export class QrScannerComponent {
private modalCtrl = inject(ModalController);
public allowedFormats = [BarcodeFormat.QR_CODE];
public availableDevices: MediaDeviceInfo[] = [];
public currentDevice: MediaDeviceInfo | undefined = undefined;
onCamerasFound(devices: MediaDeviceInfo[]) {
this.availableDevices = devices;
if (devices && devices.length > 0) {
// Cerca la fotocamera posteriore (etichette contenenti 'back', 'rear', 'environment', 'posteriore')
const backCamera = devices.find(d => {
const label = d.label.toLowerCase();
return label.includes('back') ||
label.includes('rear') ||
label.includes('environment') ||
label.includes('posteriore');
});
this.currentDevice = backCamera || devices[0];
}
}
toggleCamera() {
if (this.availableDevices.length <= 1) return;
const currentIndex = this.availableDevices.findIndex(d => d.deviceId === this.currentDevice?.deviceId);
const nextIndex = (currentIndex + 1) % this.availableDevices.length;
this.currentDevice = this.availableDevices[nextIndex];
}
get showIpadWarning(): boolean {
const isIPadDesktop =
/Macintosh/.test(navigator.userAgent) &&
navigator.maxTouchPoints !== undefined &&
navigator.maxTouchPoints > 1;
return isIPadDesktop && this.availableDevices.length <= 1;
}
onCodeResult(result: string) {
if (result) {
this.modalCtrl.dismiss(result);
}
}
onScanError(error: any) {
// Silenzia e ignora gli errori interni della libreria zxing
try {
if (error) {
// Previeni la propagazione a livello di window/console
if (typeof error.preventDefault === 'function') error.preventDefault();
if (typeof error.stopPropagation === 'function') error.stopPropagation();
}
} catch(e) {}
}
onScanFailure(failure: any) {
// Ignora i tentativi falliti di decodifica frame per frame
}
cancel() {
this.modalCtrl.dismiss();
}
}
@@ -0,0 +1,41 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">Condividi Playlist</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">
Fai scansionare questo QR Code da un altro dispositivo per condividere all'istante la playlist <strong>{{ playlistName }}</strong>.
</p>
<div class="qr-card glass">
<div class="qr-wrapper" *ngIf="qrCodeUrl">
<img [src]="qrCodeUrl" alt="QR Code della Playlist" class="qr-image" />
</div>
<div class="playlist-box">
<span class="playlist-label outfit-font">Nome Playlist:</span>
<span class="playlist-text outfit-font">{{ playlistName }}</span>
</div>
</div>
<div class="actions-wrapper">
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="shareLinkNative()">
<ion-icon name="share-social-outline" slot="start"></ion-icon>
Invia Link / Condividi
</ion-button>
<ion-button expand="block" fill="outline" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
<ion-icon name="copy-outline" slot="start"></ion-icon>
Copia Link
</ion-button>
</div>
</div>
</ion-content>
@@ -0,0 +1,90 @@
.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;
}
}
.playlist-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);
}
.playlist-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ion-color-secondary);
font-weight: 600;
}
.playlist-text {
font-size: 1rem;
color: var(--ion-text-color);
font-weight: bold;
word-break: break-word;
opacity: 0.95;
}
.actions-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 340px;
}
.action-btn {
margin: 0;
--border-radius: 14px;
font-weight: 600;
height: 48px;
}
@@ -0,0 +1,90 @@
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-share-playlist-qr-modal',
templateUrl: './share-playlist-qr-modal.component.html',
styleUrls: ['./share-playlist-qr-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule]
})
export class SharePlaylistQrModalComponent implements OnInit {
private modalCtrl = inject(ModalController);
private toastCtrl = inject(ToastController);
@Input() playlistName!: string;
@Input() shareLink!: string;
public qrCodeUrl: string = '';
ngOnInit() {
this.generateQr();
}
async generateQr() {
try {
this.qrCodeUrl = await QRCode.toDataURL(this.shareLink, {
errorCorrectionLevel: 'H',
margin: 2,
width: 400,
color: {
dark: '#1e293b',
light: '#ffffff'
}
});
} catch (err) {
console.error('Failed to generate QR Code:', err);
}
}
async shareLinkNative() {
const fileName = `${this.playlistName.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
const res = await fetch(this.qrCodeUrl);
const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' });
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla subito: ${this.shareLink}`
});
} else if (navigator.share) {
await navigator.share({
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla: ${this.shareLink}`
});
} else {
await this.copyToClipboard();
}
} catch (err) {
console.error('Share failed', err);
await this.copyToClipboard();
}
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.shareLink);
const toast = await this.toastCtrl.create({
message: 'Link 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();
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomePage } from './home.page';
const routes: Routes = [
{
path: '',
component: HomePage,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HomePageRoutingModule {}
+19
View File
@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { HomePage } from './home.page';
import { HomePageRoutingModule } from './home-routing.module';
import { DragDropModule } from '@angular/cdk/drag-drop';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
DragDropModule
],
declarations: [HomePage]
})
export class HomePageModule {}
+651
View File
@@ -0,0 +1,651 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">
<div class="header-logo-wrapper">
<img src="assets/icon/favicon.png" class="header-logo">
<div class="header-text-group">
<span class="app-name">{{ appName }}</span>
<span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
v{{ version }}
</span>
</div>
</div>
</ion-title>
<ion-buttons slot="end">
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto" *ngIf="settingsService.showEditor()">
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
</ion-button>
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
</ion-button>
</ng-container>
<ng-template #showQrBtn>
<ion-button (click)="importPlaylist()" class="add-btn">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button>
</ng-template>
<ion-button routerLink="/settings" class="settings-btn">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
<ion-toolbar class="bg-gradient" *ngIf="!playlistService.selectionMode() || isAddingSongs()">
<div class="search-wrapper-group">
<div class="search-row">
<div class="search-wrapper glass">
<ion-searchbar
placeholder="Cerca un canto..."
[value]="searchQuery()"
(ionInput)="onSearch($event)"
[disabled]="isAdvancedSearchOpen()"
class="custom-searchbar">
</ion-searchbar>
<ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
<ion-icon slot="icon-only" name="options-outline" [color]="isAdvancedSearchOpen() ? 'secondary' : 'medium'"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleVoiceSearch('global')" class="voice-search-btn" [disabled]="isAdvancedSearchOpen()">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<!-- Advanced Search & Filters Toggle Row -->
<div class="advanced-search-toggle-row">
<div class="toggle-row-left">
<div class="song-count-card outfit-font">
{{ filteredCanti().length }}
</div>
<button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
<ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
<span>Ricerche</span>
<span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
<ion-icon name="close-circle"></ion-icon>
</span>
</button>
<button type="button" class="advanced-search-link outfit-font" (click)="toggleFilterCard()">
<ion-icon [name]="isFilterCardOpen() ? 'chevron-up-outline' : 'filter-outline'"></ion-icon>
<span>Filtri</span>
<span class="inline-clear-btn" *ngIf="hasFilterCardParams()" (click)="clearFilterCard($event)" title="Reset filtri">
<ion-icon name="close-circle"></ion-icon>
</span>
</button>
<button type="button" class="advanced-search-link outfit-font" (click)="togglePlaylistCard()">
<ion-icon [name]="isPlaylistCardOpen() ? 'chevron-up-outline' : 'list-outline'"></ion-icon>
<span>Liste</span>
<span class="inline-clear-btn" *ngIf="hasPlaylistCardParams()" (click)="clearPlaylistCard($event)" title="Reset liste">
<ion-icon name="close-circle"></ion-icon>
</span>
</button>
</div>
</div>
<!-- Advanced Search Panel -->
<div class="advanced-search-panel glass outfit-font"
*ngIf="isAdvancedSearchOpen()"
(touchstart)="onPanelTouchStart($event)"
(touchend)="onPanelTouchEnd($event, 'advancedSearch')">
<div class="adv-search-grid">
<div class="adv-input-group">
<label class="adv-label">Ricerca per titolo</label>
<div class="adv-input-wrapper">
<ion-icon name="text-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Titolo canto..."
[value]="searchTitle()"
(input)="onSearchTitleInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchTitle()" (click)="searchTitle.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('title')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="adv-input-group">
<label class="adv-label">Ricerca per autore</label>
<div class="adv-input-wrapper">
<ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Nome autore..."
[value]="searchAuthor()"
(input)="onSearchAuthorInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchAuthor()" (click)="searchAuthor.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('author')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="adv-input-group">
<label class="adv-label">Ricerca per testo</label>
<div class="adv-input-wrapper">
<ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Parole nel testo..."
[value]="searchText()"
(input)="onSearchTextInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchText()" (click)="searchText.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('text')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="adv-input-group">
<label class="adv-label">Ricerca per nr canto</label>
<div class="adv-input-wrapper">
<ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Numero canto..."
[value]="searchNumber()"
(input)="onSearchNumberInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchNumber()" (click)="searchNumber.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('number')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
</div>
</div>
<!-- Playlist Card (default closed) -->
<div class="filter-card glass outfit-font"
*ngIf="isPlaylistCardOpen()"
(touchstart)="onPanelTouchStart($event)"
(touchend)="onPanelTouchEnd($event, 'playlistCard')">
<div class="filter-card-row">
<span class="filter-row-label">Playlist</span>
<div class="filter-row-items">
<!-- Comunità filter button if enabled -->
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<ion-button
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
size="small"
(click)="toggleComunitaFilter()"
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
class="filter-chip"
style="margin: 0;">
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
</ion-button>
</div>
<!-- Playlist Chips -->
<div
*ngFor="let item of playlistService.allPlaylists()"
class="momento-chip glass"
[class.active]="playlistService.activePlaylistId() === item.id"
[class.comunita-chip]="item.isComunita"
(click)="playlistService.activePlaylistId() === item.id ? clearSpecialList($event) : selectPlaylist(item)">
<ion-icon *ngIf="item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ item.name }}
<span *ngIf="playlistService.activePlaylistId() === item.id && isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 18px; height: 18px; vertical-align: middle;">
<ion-icon name="refresh-outline" style="font-size: 0.85rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
<span class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
<span *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
Nessuna playlist creata
</span>
</div>
</div>
</div>
<!-- Filter Card (default closed) -->
<div class="filter-card glass outfit-font"
*ngIf="isFilterCardOpen()"
(touchstart)="onPanelTouchStart($event)"
(touchend)="onPanelTouchEnd($event, 'filterCard')">
<!-- Row 2: Tipologia -->
<div class="filter-card-row">
<span class="filter-row-label">Tipologia</span>
<div class="filter-row-items">
<div
class="momento-chip glass"
[class.active]="showValidati()"
(click)="toggleValidati()">
Validati
<span class="close-icon-wrapper" *ngIf="showValidati()" (click)="clearListaCompleta($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</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>
<span class="close-icon-wrapper" *ngIf="showNonValidati()" (click)="clearListaCompleta($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
</div>
</div>
<!-- Row 3: Liturgia -->
<div class="filter-card-row">
<span class="filter-row-label">Liturgia</span>
<div class="filter-row-items">
<div
*ngFor="let item of cantiService.indiceLiturgico()"
class="momento-chip glass"
[class.active]="selectedLiturgico() === item.id"
(click)="toggleIndex(item.id, 'liturgico')">
{{ item.tag_name }}
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() === item.id" (click)="clearLiturgico($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
</div>
</div>
<!-- Row 4: Periodo -->
<div class="filter-card-row">
<span class="filter-row-label">Periodo</span>
<div class="filter-row-items">
<div
*ngFor="let item of cantiService.indiceTematico()"
class="momento-chip glass"
[class.active]="selectedTematico() === item.id"
(click)="toggleIndex(item.id, 'tematico')">
{{ item.tag_name }}
<span class="close-icon-wrapper" *ngIf="selectedTematico() === item.id" (click)="clearTematico($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
</div>
</div>
<!-- Row 5 (Footer): Suggeriti, Top Ten & Reset filtri -->
<div class="filter-card-footer">
<div class="footer-actions">
<ion-button
[fill]="showSuggeriti() ? 'solid' : 'outline'"
size="small"
(click)="toggleSuggeriti()"
class="filter-chip">
Suggeriti
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="showTopTen() ? 'solid' : 'outline'"
size="small"
(click)="toggleTopTen()"
class="filter-chip">
Top Ten
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="showOnlyMine() ? 'solid' : 'outline'"
size="small"
(click)="toggleOnlyMine()"
class="filter-chip">
Miei
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
</div>
</div>
</div>
</div>
</ion-toolbar>
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
<div class="playlist-actions-bar">
<!-- Duration Badge on the left -->
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
<ion-icon name="time-outline"></ion-icon>
<span>{{ totalPlaylistDuration() }}</span>
</div>
<!-- Playlist Name in middle -->
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
</div>
<!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button>
</div>
<!-- Selection Mode Actions -->
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
<span class="selection-count">{{ reorderList().length }}</span>
<input
type="text"
[value]="playlistService.activeListName() || ''"
(input)="playlistService.activeListName.set($any($event.target).value)"
placeholder="Nome playlist..."
class="playlist-name-input outfit-font"
/>
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn" *ngIf="reorderList().length > 0">
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="finishSelection()" [disabled]="reorderList().length === 0" class="mini-action-btn" *ngIf="reorderList().length > 0">
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="cancelSelection()" class="mini-action-btn" *ngIf="reorderList().length > 0">
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
</ion-header>
<ion-content class="bg-gradient"
(touchstart)="onTouchStart($event); onInteraction()"
(touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()"
(click)="onInteraction()">
<div class="ion-padding no-padding-top">
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
<div class="loading-wrapper">
<ion-spinner name="crescent" color="secondary"></ion-spinner>
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
<ion-progress-bar [value]="cantiService.progress() / 100" color="secondary" style="width: 200px; border-radius: 10px; height: 8px; margin-top: 8px;"></ion-progress-bar>
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
</div>
</div>
<!-- Wrap in DragDrop only in Reorder mode -->
<div cdkDropList
[cdkDropListDisabled]="!playlistService.selectionMode() || isAddingSongs()"
(cdkDropListDropped)="drop($event)"
class="transparent-list">
<!-- Premium Suggested Mass Card (Collapsible with Swipe support) -->
<div class="suggeriti-header-card glass ion-margin-bottom"
*ngIf="showSuggeriti() && cantiLettureService.data()"
(click)="isMassCardExpanded.set(!isMassCardExpanded())"
(touchstart)="onMassCardTouchStart($event)"
(touchend)="onMassCardTouchEnd($event)"
[class.expanded]="isMassCardExpanded()">
<div class="card-inner ion-padding">
<div class="card-header-row">
<div class="card-header-left">
<span class="card-label">SINTESI MESSA</span>
<span class="card-date-compact" *ngIf="!isMassCardExpanded()">{{ getSelectedMassFormattedDate() }}</span>
</div>
<div class="card-header-right" style="display: flex; align-items: center; gap: 8px;">
<div class="mass-nav-buttons" style="display: flex; align-items: center; gap: 4px; z-index: 10;" (click)="$event.stopPropagation()">
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(-1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
<ion-icon slot="icon-only" name="chevron-back-outline" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
<ion-icon slot="icon-only" name="chevron-forward-outline" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
</div>
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon" style="margin-left: 4px;"></ion-icon>
</div>
</div>
<div class="expandable-content" [class.show]="isMassCardExpanded()">
<div class="card-date-row" *ngIf="isMassCardExpanded()">
<span class="card-date">{{ getSelectedMassFormattedDate() }}</span>
</div>
<h1 class="card-title outfit-font">{{ getSelectedMassTitle() }}</h1>
<p class="card-summary outfit-font" *ngIf="getSelectedMassSummary()">
{{ getSelectedMassSummary() }}
</p>
</div>
</div>
</div>
<ion-item *ngFor="let canto of visibleCanti()"
[id]="'canto-' + canto.id"
class="glass ion-margin-bottom custom-item"
[class.is-playing]="youtubePlayerService.currentCantoId() === canto.id"
cdkDrag
[cdkDragDisabled]="!playlistService.selectionMode() || isAddingSongs()">
<!-- Drag Handle (visible only in reorder mode) -->
<div class="drag-handle" cdkDragHandle *ngIf="playlistService.selectionMode() && !isAddingSongs()">
<ion-icon name="reorder-two-outline"></ion-icon>
</div>
<div class="item-wrapper">
<!-- Selection Area -->
<div class="selection-column" (click)="toggleSongSelection(canto.id); $event.stopPropagation()">
<span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)">
{{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
</span>
</div>
<!-- Content Area -->
<div class="content-column" (click)="goToCanto(canto.id)">
<div class="top-row">
<ion-label class="info-section">
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary); display: flex; align-items: center; flex-wrap: wrap; gap: 4px;">
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span>
<span>{{ canto.titolo }}</span>
<span *ngIf="canto.nonValidato && !canto.id.startsWith('my_')" class="non-validato-badge remote-badge">Remoto</span>
<span *ngIf="canto.id.startsWith('my_')" class="non-validato-badge mio-badge">Mio</span>
</h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
</p>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px;"
*ngIf="(settingsService.showDurationBpmTonality() && (canto.durata || canto.bpm || getSongTonality(canto))) || (settingsService.showUpdateDate() && canto.data_update) || (showTopTen() && getEsecuzioniCount(canto.id) !== null) || (showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null)">
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
<span *ngIf="canto.durata" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="time-outline" style="font-size: 0.75rem;"></ion-icon>
{{ canto.durata }}
</span>
<span *ngIf="canto.bpm" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="pulse-outline" style="font-size: 0.75rem;"></ion-icon>
{{ canto.bpm }} BPM
</span>
<span *ngIf="getSongTonality(canto)" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="musical-note-outline" style="font-size: 0.75rem;"></ion-icon>
{{ getSongTonality(canto) }}
</span>
</ng-container>
<span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;">
<ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon>
agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
</span>
<span *ngIf="showTopTen() && getEsecuzioniCount(canto.id) !== null" class="esecuzioni-badge" style="color: var(--ion-color-secondary); font-size: 0.75rem; background: rgba(var(--ion-color-secondary-rgb), 0.12); padding: 2px 6px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.2);">
<ion-icon name="stats-chart-outline" style="font-size: 0.75rem; color: var(--ion-color-secondary);"></ion-icon>
{{ getEsecuzioniCount(canto.id) }} esecuzioni
</span>
<span *ngIf="showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null" class="suggerito-badge" style="color: var(--ion-color-secondary); font-size: 0.75rem; background: rgba(var(--ion-color-secondary-rgb), 0.12); padding: 2px 6px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);">
<ion-icon name="sparkles-outline" style="font-size: 0.75rem; color: var(--ion-color-secondary);"></ion-icon>
Attinenza {{ getMassSuggestionWeight(canto.id_canti) }}%
</span>
</p>
<!-- Visualizza tag sotto autore -->
<div *ngIf="settingsService.showTagsInList() && cantiService.getSongTags(canto).length > 0" class="song-tags-list">
<span *ngFor="let tag of cantiService.getSongTags(canto)" class="song-tag-badge">
{{ tag }}
</span>
</div>
</ion-label>
<!-- Video Thumbnail Section -->
<div *ngIf="cantiService.getYoutubeId(canto.link_youtube)" class="thumb-section" (click)="$event.stopPropagation()">
<div class="thumb-container compact">
<ng-container *ngIf="youtubePlayerService.isPlayerSupported(); else noPlayerThumb">
<div class="thumb-wrapper" (click)="playVideo($event, canto.id)">
<img [src]="cantiService.getYoutubeThumb(canto.link_youtube)"
class="thumb-img loaded"
loading="lazy">
<div class="play-overlay youtube-overlay">
<ion-icon name="play-sharp"></ion-icon>
</div>
</div>
</ng-container>
<ng-template #noPlayerThumb>
<div class="thumb-wrapper">
<img [src]="cantiService.getYoutubeThumb(canto.link_youtube)"
class="thumb-img loaded"
loading="lazy"
*ngIf="connectivityService.isOnline(); else offlineIcon">
<ng-template #offlineIcon>
<div class="offline-thumb">
<ion-icon name="cloud-offline-outline"></ion-icon>
</div>
</ng-template>
</div>
</ng-template>
</div>
</div>
<!-- Delete Button (only for "mio" songs when "Miei" filter is active) -->
<div *ngIf="settingsService.showEditor() && canto.id.startsWith('my_') && showOnlyMine()" class="delete-section" (click)="$event.stopPropagation()">
<ion-button fill="clear" color="danger" (click)="deleteMyCanto(canto.id, $event)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-button>
</div>
</div>
</div>
<!-- Player bars removed from here -->
</div>
</ion-item>
</div>
<!-- Add songs / Toggle View Button -->
<div class="ion-text-center ion-margin-top" *ngIf="playlistService.selectionMode()">
<ion-button fill="solid" color="secondary" (click)="toggleAddingSongs()" class="action-mode-btn glass">
<ion-icon slot="start" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
{{ isAddingSongs() ? 'Torna alla Playlist' : 'Aggiungi altri canti' }}
</ion-button>
</div>
<div *ngIf="!cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding">
<p style="color: rgba(255,255,255,0.5)">Nessun canto trovato.</p>
</div>
</div>
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="(comunitaService.isFilterActive() && comunitaService.comunitaCode()) || limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
<ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
</ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>
<ion-footer *ngIf="youtubePlayerService.currentCantoId()" class="ion-no-border">
<ion-toolbar class="global-player-toolbar glass">
<div class="player-content">
<div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="playPrevPreview($event)" class="skip-btn">
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button>
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && getPlayingCanto()?.link_youtube && getPlayingCanto()?.link_youtube!.length > 5; else noAudioMsg">
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
<ion-range
[min]="0"
[max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)"
(ionKnobMoveStart)="onSeekStart()"
(ionKnobMoveEnd)="onSeekEnd()"
color="secondary"
class="global-range"
style="margin: 0; padding: 0 4px;">
</ion-range>
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
</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);">
Audio non disponibile
</div>
</ng-template>
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
</ion-footer>
<div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)">
<div class="black-screen-content" (click)="$event.stopPropagation()">
<div (click)="isBlackOverlayDismissed.set(true)" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
</div>
<div class="car-mode-canto-info" *ngIf="getPlayingCanto()">
<h2 class="car-mode-canto-title outfit-font">{{ getPlayingCanto()?.titolo }}</h2>
<p class="car-mode-canto-author outfit-font">{{ getPlayingCanto()?.autore }}</p>
</div>
<div class="car-mode-progress outfit-font" *ngIf="youtubePlayerService.currentCantoId()">
<span class="car-mode-time">{{ formatSeconds(youtubePlayerService.videoProgress()) }} / {{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
</div>
<div class="car-mode-controls">
<ion-button fill="clear" (click)="playPrevPreview($event)" class="car-mode-btn">
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="youtubePlayerService.isPlaying() ? pauseVideo($event) : playVideo($event, youtubePlayerService.currentCantoId()!)" class="car-mode-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="playNextPreview($event)" class="car-mode-btn">
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
</ion-button>
</div>
<p class="car-mode-subtext outfit-font" (click)="isBlackOverlayDismissed.set(true)">Tocca lo sfondo o l'icona per sbloccare</p>
</div>
</div>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';
describe('HomePage', () => {
let component: HomePage;
let fixture: ComponentFixture<HomePage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomePage],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(HomePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
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);
}
}
@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { DisplayPage } from './display.page';
const routes: Routes = [
{
path: '',
component: DisplayPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DisplayPageRoutingModule {}
+20
View File
@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { DisplayPageRoutingModule } from './display-routing.module';
import { DisplayPage } from './display.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
DisplayPageRoutingModule
],
declarations: [DisplayPage]
})
export class DisplayPageModule {}
+46
View File
@@ -0,0 +1,46 @@
<ion-content [fullscreen]="true" class="bg-gradient">
<div class="display-wrapper"
[style.fontSize.rem]="fontSize()"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()">
<div class="lyrics-window">
<!-- Previous line -->
<div *ngIf="displayLines().prev" class="prev-line">
<ng-container *ngIf="showChords(); else prevText">
<span *ngFor="let seg of displayLines().prev!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().prev!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<ng-template #prevText>{{ displayLines().prev!.text }}</ng-template>
</div>
<!-- Active line -->
<div *ngIf="displayLines().current" class="active-line outfit-font">
<ng-container *ngIf="showChords(); else currentText">
<span *ngFor="let seg of displayLines().current!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().current!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<ng-template #currentText>{{ displayLines().current!.text }}</ng-template>
</div>
<!-- Next line -->
<div *ngIf="displayLines().next" class="next-line">
<ng-container *ngIf="showChords(); else nextText">
<span *ngFor="let seg of displayLines().next!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().next!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<ng-template #nextText>{{ displayLines().next!.text }}</ng-template>
</div>
</div>
<div class="footer-info">
<div class="title-label outfit-font">{{ canto()?.titolo }}</div>
</div>
</div>
</ion-content>
+95
View File
@@ -0,0 +1,95 @@
.bg-gradient {
--background: radial-gradient(circle at top, #1e272e 0%, #000000 100%);
background: radial-gradient(circle at top, #1e272e 0%, #000000 100%);
}
.display-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 5% 10%;
text-align: center;
transition: font-size 0.2s ease;
touch-action: none; /* Support pinch-to-zoom */
}
.lyrics-window {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2rem;
width: 100%;
}
.active-line {
font-size: 5rem;
font-weight: 700;
color: var(--ion-color-secondary);
text-shadow: 0 0 20px rgba(253, 203, 110, 0.4);
line-height: 1.2;
}
.prev-line, .next-line {
font-size: 2.5rem;
color: rgba(255, 255, 255, 0.2);
font-weight: 300;
}
// ─── Chord segments for display ────────────────
.chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
}
.chord {
color: #e74c3c;
font-weight: 700;
font-size: 0.4em;
line-height: 1.2;
min-height: 0.5em;
font-family: 'Outfit', sans-serif;
letter-spacing: 0.5px;
padding-right: 0.25em;
}
.active-line .chord {
color: #e74c3c;
font-size: 0.35em;
}
.prev-line .chord, .next-line .chord {
color: rgba(231, 76, 60, 0.4);
font-size: 0.5em;
}
.active-line, .prev-line, .next-line {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: center;
}
.seg-text {
white-space: pre-wrap;
&::after {
content: '\200b';
}
}
.footer-info {
margin-top: 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 1rem;
width: 100%;
.title-label {
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 2px;
}
}
@@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DisplayPage } from './display.page';
describe('DisplayPage', () => {
let component: DisplayPage;
let fixture: ComponentFixture<DisplayPage>;
beforeEach(() => {
fixture = TestBed.createComponent(DisplayPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+177
View File
@@ -0,0 +1,177 @@
import { Component, OnInit, OnDestroy, signal, computed, effect, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CantiService, Canto } from '../../services/canti.service';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
import { ComunitaService } from '../../services/comunita.service';
@Component({
selector: 'app-display',
templateUrl: './display.page.html',
styleUrls: ['./display.page.scss'],
standalone: false
})
export class DisplayPage implements OnInit, OnDestroy {
public canto = signal<Canto | null>(null);
public activeSongId = signal<string | null>(null);
public showChords = signal<boolean>(false);
public fontSize = signal<number>(1.0);
public currentLineIndex = signal<number>(0);
public transposeAmount = signal<number>(0);
public parsedSections = computed<ParsedSection[]>(() => {
const c = this.canto();
if (!c) return [];
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);
}
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 */
public displayLines = computed(() => {
const sections = this.parsedSections();
const idx = this.currentLineIndex();
const allLines: { text: string; segments: any[]; sectionType: string }[] = [];
for (const section of sections) {
for (const line of section.lines) {
allLines.push({ text: line.text, segments: line.segments, sectionType: section.type });
}
}
return {
prev: idx > 0 ? allLines[idx - 1] : null,
current: allLines[idx] || null,
next: idx < allLines.length - 1 ? allLines[idx + 1] : null
};
});
private channel = new BroadcastChannel('karaoke_sync');
private readonly MIN_FONT = 0.6;
private readonly MAX_FONT = 5.0;
private initialPinchDistance: number | null = null;
private initialFontSize: number = 1.0;
private route = inject(ActivatedRoute);
private cantiService = inject(CantiService);
public lyricsParser = inject(LyricsParserService);
private comunitaService = inject(ComunitaService);
constructor() {
effect(() => {
const id = this.activeSongId();
const allCanti = this.cantiService.canti();
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
if (id) {
let found: any = null;
if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
found = comunitaCantiPers.find(c => c.id === id);
}
if (!found) {
found = allCanti.find(c => c.id === id);
}
if (!found) {
found = comunitaCantiPers.find(c => c.id === id);
}
if (found) {
this.canto.set(found);
}
}
}, { allowSignalWrites: true });
}
ngOnInit() {
this.route.queryParams.subscribe(params => {
const id = params['id'];
if (id) {
this.activeSongId.set(id);
}
});
this.channel.onmessage = (event) => {
if (event.data.type === 'SYNC_INDEX') {
this.currentLineIndex.set(event.data.index);
}
if (event.data.type === 'SYNC_CANTO') {
this.activeSongId.set(event.data.id);
this.transposeAmount.set(0);
}
if (event.data.type === 'SYNC_CHORDS') {
this.showChords.set(event.data.showChords);
}
if (event.data.type === 'SYNC_FONT') {
this.fontSize.set(event.data.fontSize);
}
if (event.data.type === 'SYNC_TRANSPOSE') {
this.transposeAmount.set(event.data.amount);
}
};
}
ngOnDestroy() {
this.channel.close();
}
// --- Touch Gestures for Pinch-to-Zoom ---
onTouchStart(event: TouchEvent) {
if (event.touches.length === 2) {
this.initialPinchDistance = this.getDistance(event.touches[0], event.touches[1]);
this.initialFontSize = this.fontSize();
}
}
onTouchMove(event: TouchEvent) {
if (event.touches.length === 2 && this.initialPinchDistance !== null) {
event.preventDefault(); // Prevent browser zoom/scroll
const currentDistance = this.getDistance(event.touches[0], event.touches[1]);
const ratio = currentDistance / this.initialPinchDistance;
let newSize = this.initialFontSize * ratio;
// Clamp values
newSize = Math.max(this.MIN_FONT, Math.min(this.MAX_FONT, newSize));
if (Math.abs(newSize - this.fontSize()) > 0.01) {
this.fontSize.set(newSize);
// Sync back to player if display is touched
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
}
}
}
onTouchEnd() {
this.initialPinchDistance = null;
}
private getDistance(t1: Touch, t2: Touch): number {
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
}
// ----------------------------------------
}
@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { PlayerPage } from './player.page';
const routes: Routes = [
{
path: '',
component: PlayerPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PlayerPageRoutingModule {}
+21
View File
@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { IonicModule } from '@ionic/angular';
import { PlayerPageRoutingModule } from './player-routing.module';
import { PlayerPage } from './player.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
PlayerPageRoutingModule,
DragDropModule
],
declarations: [PlayerPage]
})
export class PlayerPageModule {}
+369
View File
@@ -0,0 +1,369 @@
<ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
<div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
<!-- Row 1: Number + Title + Settings -->
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
[style.fontSize.rem]="0.85 * fontSize()"
[style.width.px]="32 * fontSize()"
[style.height.px]="32 * fontSize()"
[style.lineHeight.px]="28 * fontSize()"
[style.borderRadius.px]="8 * fontSize()">
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span>
<span class="canto-title"
[style.fontSize.rem]="1.15 * fontSize()"
style="font-weight: 700; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span>
</span>
<ion-button routerLink="/settings" class="settings-btn" fill="clear" style="flex-shrink: 0; margin: 0; --color: var(--ion-color-secondary);">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button>
</div>
<!-- Row 2: Author -->
<div *ngIf="!isLandscapeActive()"
style="display: block; width: 100%; margin-top: 1px; margin-bottom: 2px;"
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
<span class="song-author"
[style.fontSize.rem]="0.95 * fontSize()"
style="color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
{{ canto()?.autore || 'Autore sconosciuto' }}
</span>
</div>
<!-- Row 3: Badges only -->
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
style="margin-top: 2px; width: 100%;"
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
<div style="display: flex; align-items: center; gap: inherit;">
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
<span *ngIf="canto()?.durata" class="song-duration-badge"
[style.fontSize.rem]="0.75 * fontSize()"
[style.height.px]="26 * fontSize()"
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
{{ canto()?.durata }}
</span>
<span *ngIf="canto()?.bpm" class="song-bpm-badge"
[style.fontSize.rem]="0.75 * fontSize()"
[style.height.px]="26 * fontSize()"
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="pulse-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
{{ canto()?.bpm }} BPM
</span>
<span *ngIf="tonality()" class="song-tonality-badge"
[style.fontSize.rem]="0.75 * fontSize()"
[style.height.px]="26 * fontSize()"
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="musical-note-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
{{ tonality() }}
</span>
</ng-container>
</div>
</div>
</div>
</ion-toolbar>
<!-- Audio Toolbar Removed -->
</ion-header>
<ion-content class="bg-gradient" [class.full-screen-content]="settingsService.fullscreenMode()">
<div class="lyrics-container"
[style.fontSize.rem]="fontSize()"
[class.full-screen-container]="settingsService.fullscreenMode()"
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()">
<!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container">
<!-- Navigation Group -->
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
prv
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-up" color="secondary"></ion-icon>
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-down" color="secondary"></ion-icon>
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="landscape-playlist-btn next-btn">
nxt
</ion-button>
</div>
<!-- Zoom Group -->
<div class="side-group">
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
<div class="side-indicator" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">
<ion-icon name="search-outline" color="secondary" style="font-size: 1.0rem;"></ion-icon>
<span class="side-val" style="font-size: 0.7rem; font-weight: 700; color: var(--ion-color-secondary);">{{ fontSize().toFixed(1) }}</span>
</div>
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Autoscroll Standard Group -->
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()">
<ion-button fill="clear" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="side-indicator" (click)="toggleAutoscroll()">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
<span class="side-val">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
</div>
</div>
<!-- Sections rendering -->
<div class="lyrics-view" (click)="handleLyricsClick($event)">
<div *ngFor="let section of parsedSections(); let si = index"
class="section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<!-- Section label for chorus -->
<div *ngIf="section.type === 'chorus'" class="section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="section-label verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines; let li = index"
class="lyric-line"
[class.active]="isActiveLine(si, li)">
<!-- Chord mode: show chords above text -->
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
</div>
</div>
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima sullo schermo -->
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
</div>
</div>
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
<video #faceVideo id="face-preview-video" muted playsinline autoplay style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; z-index: -1;"></video>
</ion-content>
<ion-footer class="ion-no-border">
<!-- Audio Player / Playlist Navigation Toolbar -->
<ion-toolbar class="global-player-toolbar glass">
<div class="player-content">
<div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="goHome()" class="skip-btn">
<ion-icon slot="icon-only" name="home-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button>
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
<ion-range
[min]="0"
[max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)"
color="secondary"
class="global-range"
style="margin: 0; padding: 0 4px;">
</ion-range>
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
</ng-container>
<ng-template #noAudioMsg>
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
Nessun audio disponibile
</div>
</ng-template>
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
<ion-toolbar class="bg-gradient slim-toolbar">
<div class="slim-controls">
<!-- Autoscroll Standard in Portrait Footer -->
<div class="group" *ngIf="settingsService.enableStandardAutoscroll()">
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="autoscroll-indicator" (click)="toggleAutoscroll()" style="display: flex; align-items: center; gap: 4px; padding: 0 4px; cursor: pointer;">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
<span style="font-size: 0.75rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 32px; text-align: center;">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
<!-- Camera Gestures Toggle in Portrait (Tilt or Distance) -->
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button>
<!-- Indicator in Distance Mode -->
<div class="camera-indicator distance-mode"
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'distance'"
[class.approaching]="faceDetector.distanceState() === 'approaching'"
[class.receding]="faceDetector.distanceState() === 'receding'"
(click)="recalibrateDistance($event)"
title="Tocca per ricalibrare e impostare la tua distanza attuale come 100%">
<ion-icon [name]="faceDetector.distanceState() === 'approaching' ? 'arrow-down-circle' : (faceDetector.distanceState() === 'receding' ? 'arrow-up-circle' : 'scan-outline')"></ion-icon>
<span class="val">{{ (faceDetector.currentDistanceRatio() * 100).toFixed(0) }}%</span>
</div>
<!-- Indicator in Tilt Mode -->
<div class="camera-indicator"
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'tilt'"
[class.tilted]="faceDetector.isTilted()">
<ion-icon name="person-outline"
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
</div>
</div>
<!-- Navigation (Avanzamento Karaoke e Riparti da inizio) -->
<div class="group">
<ion-button fill="clear" size="small" (click)="restart()">
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-back" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-forward" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Action Buttons (edit, condividi, accordi) -->
<div class="group">
<ion-button fill="clear" size="small" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()">
<ion-icon slot="icon-only" name="create-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="deleteMyCanto()" *ngIf="settingsService.showEditor() && canto()?.id?.startsWith('my_')" color="danger">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="shareCanto()">
<ion-icon slot="icon-only" name="share-social-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="toggleChords()">
<ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
[color]="showChords() ? 'secondary' : 'medium'">
</ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="printCanto()" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Transposition (Only in chords mode) -->
<div class="group" *ngIf="showChords()">
<ion-button fill="clear" size="small" (click)="transposeDown()">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="transpose-indicator">
<ion-icon name="musical-note" color="secondary"></ion-icon>
<span class="val" *ngIf="tonality()">{{ tonality() }}</span>
<span class="val" *ngIf="transposeAmount() !== 0" style="opacity: 0.8; font-size: 0.75rem; margin-left: 2px;">({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})</span>
</div>
<ion-button fill="clear" size="small" (click)="transposeUp()">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
<!-- Zoom -->
<div class="group">
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
<div class="zoom-indicator">
<ion-icon name="search-outline" color="secondary"></ion-icon>
<span class="val">{{ fontSize().toFixed(1) }}</span>
</div>
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- YouTube Link in Portrait Footer -->
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
<ion-button fill="clear" size="small" (click)="openYoutube()">
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
</ion-footer>
<!-- Black Screen Overlay -->
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()">
<div class="black-screen-content" (click)="$event.stopPropagation()">
<div (click)="deactivateBlackScreen()" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
</div>
<div class="car-mode-canto-info" *ngIf="canto()">
<h2 class="car-mode-canto-title outfit-font">{{ canto()?.titolo }}</h2>
<p class="car-mode-canto-author outfit-font">{{ canto()?.autore }}</p>
</div>
<div class="car-mode-progress outfit-font" *ngIf="youtubePlayerService.currentCantoId()">
<span class="car-mode-time">{{ formatSeconds(youtubePlayerService.videoProgress()) }} / {{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
</div>
<div class="car-mode-controls">
<ion-button fill="clear" (click)="prevSong()" class="car-mode-btn">
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleAudio()" class="car-mode-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="nextSong()" class="car-mode-btn">
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
</ion-button>
</div>
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerPage } from './player.page';
describe('PlayerPage', () => {
let component: PlayerPage;
let fixture: ComponentFixture<PlayerPage>;
beforeEach(() => {
fixture = TestBed.createComponent(PlayerPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { PlaylistPage } from './playlist.page';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: '',
component: PlaylistPage
}
];
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
DragDropModule,
RouterModule.forChild(routes)
],
declarations: [PlaylistPage]
})
export class PlaylistPageModule {}
+52
View File
@@ -0,0 +1,52 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-buttons slot="start">
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons>
<ion-title class="outfit-font">Gestione Playlist</ion-title>
<ion-buttons slot="end">
<ion-button (click)="exportPlaylistPdf()" [disabled]="localSongs.length === 0" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
</ion-button>
<ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="bg-gradient">
<div class="ion-padding">
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
<p style="margin: 0;">Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
<div *ngIf="totalDuration" style="background: rgba(var(--ion-color-secondary-rgb), 0.25); border: 1px solid var(--ion-color-secondary); padding: 4px 10px; border-radius: 10px; font-weight: 800; color: var(--ion-color-secondary); display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-family: 'Outfit', sans-serif; white-space: nowrap; margin-left: 12px;">
<ion-icon name="time-outline" style="font-size: 1rem;"></ion-icon>
{{ totalDuration }}
</div>
</div>
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
<div class="song-item glass ion-margin-bottom" *ngFor="let song of localSongs" cdkDrag>
<div class="drag-handle" cdkDragHandle>
<ion-icon name="reorder-two-outline"></ion-icon>
</div>
<div class="song-info">
<span class="canto-number">{{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }}</span>
<span class="song-title">
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
</span>
</div>
<div class="example-custom-placeholder" *cdkDragPlaceholder></div>
</div>
</div>
<div *ngIf="localSongs.length === 0" class="empty-state ion-text-center">
<ion-icon name="musical-notes-outline" class="large-icon"></ion-icon>
<p>Nessun canto selezionato.</p>
<ion-button routerLink="/home" fill="outline" color="secondary" class="ion-margin-top">
Torna alla Home
</ion-button>
</div>
</div>
</ion-content>
+179
View File
@@ -0,0 +1,179 @@
.song-list {
display: block;
}
.song-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 12px;
cursor: move;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
.drag-handle {
margin-right: 12px;
color: rgba(255, 255, 255, 0.3);
font-size: 1.5rem;
display: flex;
align-items: center;
}
.song-info {
display: flex;
align-items: center;
gap: 8px;
.canto-number {
background: rgba(var(--ion-color-secondary-rgb), 0.1);
color: var(--ion-color-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 700;
}
.song-title {
font-weight: 500;
color: white;
}
}
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
background: #1e1e1e;
padding: 12px;
display: flex;
align-items: center;
color: white;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.song-list.cdk-drop-list-dragging .song-item:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.header-info {
padding: 12px;
border-radius: 12px;
p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
line-height: 1.4;
}
}
.empty-state {
margin-top: 100px;
.large-icon {
font-size: 5rem;
color: rgba(255, 255, 255, 0.1);
}
p {
color: rgba(255, 255, 255, 0.5);
margin-top: 16px;
}
}
.qr-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(5px);
.qr-card {
background: #ffffff;
padding: 24px;
border-radius: 20px;
width: 90%;
max-width: 350px;
text-align: center;
color: #000;
.qr-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 { margin: 0; text-align: left; }
}
p {
font-size: 0.9rem;
color: #666;
margin-bottom: 24px;
text-align: left;
}
.qr-img-container {
background: #f8f8f8;
padding: 16px;
border-radius: 16px;
margin-bottom: 24px;
img {
width: 100%;
height: auto;
display: block;
}
}
.qr-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
}
}
.save-export-fab {
--border-radius: 30px;
width: auto;
min-width: 160px;
.fab-content {
display: flex;
align-items: center;
padding: 0 16px;
gap: 8px;
ion-icon {
font-size: 1.4rem;
}
.fab-label {
font-size: 0.9rem;
font-weight: 600;
text-transform: none;
}
}
}
:host-context(body.high-contrast) {
.song-item {
background: #ffffff !important;
border-color: #000000 !important;
.song-title { color: #000000 !important; }
.drag-handle { color: #000000 !important; }
}
.header-info p { color: #000000 !important; }
}
+313
View File
@@ -0,0 +1,313 @@
import { Component, inject, computed } from '@angular/core';
import { PlaylistService } from '../../services/playlist.service';
import { CantiService } from '../../services/canti.service';
import { ComunitaService } from '../../services/comunita.service';
import { MyCantiService } from '../../services/my-canti.service';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { AlertController, ToastController, ModalController } from '@ionic/angular';
import { Router } from '@angular/router';
@Component({
selector: 'app-playlist',
templateUrl: './playlist.page.html',
styleUrls: ['./playlist.page.scss'],
standalone: false
})
export class PlaylistPage {
public playlistService = inject(PlaylistService);
public cantiService = inject(CantiService);
public comunitaService = inject(ComunitaService);
public myCantiService = inject(MyCantiService);
private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController);
private router = inject(Router);
public selectedSongs = computed(() => {
const ids = Array.from(this.playlistService.selectedIds());
const allCanti = this.cantiService.canti();
const myCantiList = this.myCantiService.myCanti();
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
const communityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive();
return ids.map(id => {
let found: any = null;
if (communityActive) {
found = comunitaCantiPers.find(c => c.id === id);
}
if (!found) {
found = allCanti.find(c => c.id === id);
}
if (!found) {
found = myCantiList.find(c => c.id === id);
}
if (!found) {
found = comunitaCantiPers.find(c => c.id === id);
}
return found;
}).filter(c => !!c);
});
public localSongs: any[] = [];
public qrCodeImage: string | null = null;
public savedPlaylistName: string | null = null;
get totalDuration(): string {
const songs = this.localSongs;
if (songs.length === 0) return '';
let totalSeconds = 0;
for (const song of songs) {
if (song && song.durata) {
totalSeconds += this.parseDuration(song.durata);
}
}
if (totalSeconds === 0) return '';
return this.formatTotalDuration(totalSeconds);
}
private parseDuration(dur: string): number {
if (!dur) return 0;
const parts = dur.split(':').map(Number);
if (parts.some(isNaN)) return 0;
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
} else if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return 0;
}
private formatTotalDuration(seconds: number): string {
const hh = Math.floor(seconds / 3600);
const mm = Math.floor((seconds % 3600) / 60);
const ss = seconds % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
if (hh > 0) {
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
return `${pad(mm)}:${pad(ss)}`;
}
constructor() {
// Initial copy to allow local reordering
this.localSongs = [...this.selectedSongs()];
}
drop(event: CdkDragDrop<string[]>) {
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex);
}
getPlaylistSongSettings(): any {
const activeId = this.playlistService.activePlaylistId();
if (activeId) {
const pl = this.playlistService.playlists().find(p => p.id === activeId);
return pl ? pl.songSettings : undefined;
}
return undefined;
}
async savePlaylist() {
const alert = await this.alertCtrl.create({
header: 'Salva Playlist',
inputs: [
{
name: 'name',
type: 'text',
placeholder: 'Nome della playlist',
value: this.savedPlaylistName || ''
}
],
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Salva',
handler: (data) => {
if (data.name) {
this.savedPlaylistName = data.name;
this.playlistService.savePlaylist(data.name, this.localSongs.map(s => s.id), this.getPlaylistSongSettings());
this.showToast('Playlist salvata!');
this.router.navigate(['/settings']);
return true;
}
return false;
}
}
]
});
await alert.present();
}
async saveAndExport() {
const alert = await this.alertCtrl.create({
header: 'Salva ed Esporta QR',
message: 'Inserisci un nome per la playlist. Verrà salvata e generato il QR Code.',
inputs: [
{
name: 'name',
type: 'text',
placeholder: 'Nome della playlist',
value: this.savedPlaylistName || ''
}
],
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Salva ed Esporta',
handler: async (data) => {
if (data.name) {
this.savedPlaylistName = data.name;
const ids = this.localSongs.map(s => s.id);
const songSettings = this.getPlaylistSongSettings();
await this.playlistService.savePlaylist(data.name, ids, songSettings);
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name, songSettings);
this.showToast('Playlist salvata!');
return true;
}
return false;
}
}
]
});
await alert.present();
}
async sharePlaylist() {
if (this.localSongs.length === 0) return;
const ids = this.localSongs.map(s => s.id);
const name = this.savedPlaylistName || 'Playlist Condivisa';
const songSettings = this.getPlaylistSongSettings();
this.qrCodeImage = await this.playlistService.generateQR(ids, name, songSettings);
}
downloadQR() {
if (!this.qrCodeImage) return;
const name = this.savedPlaylistName || 'playlist';
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
const link = document.createElement('a');
link.href = this.qrCodeImage;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.showToast('Immagine scaricata!');
}
async shareQR() {
if (!this.qrCodeImage) return;
const name = this.savedPlaylistName || 'playlist';
const songSettings = this.getPlaylistSongSettings();
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name, songSettings);
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
// Convert base64 to blob/file for sharing
const res = await fetch(this.qrCodeImage);
const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' });
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
});
} else {
// Fallback to simple share text or download
if (navigator.share) {
await navigator.share({
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
});
} else {
// Copy to clipboard AND download QR!
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
this.showToast('Link copiato negli appunti! QR scaricato.');
}
} catch(e) {}
this.downloadQR();
}
}
} catch (err) {
console.error('Share failed', err);
this.downloadQR();
}
}
closeQR() {
this.qrCodeImage = null;
}
async showToast(message: string) {
const toast = await this.toastCtrl.create({
message,
duration: 2000,
color: 'success'
});
await toast.present();
}
getCommunitySongNumber(song: any): string | null {
if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null;
const cantiInfo = this.comunitaService.comunitaCantiInfo();
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
return info && info.num_canto ? info.num_canto.toString() : null;
}
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;
}
async exportPlaylistPdf() {
if (this.localSongs.length === 0) return;
const songSettings = this.getPlaylistSongSettings() || {};
const transpositions: { [songId: string]: number } = {};
for (const key of Object.keys(songSettings)) {
if (songSettings[key] && songSettings[key].tonalita !== undefined) {
transpositions[key] = songSettings[key].tonalita;
}
}
const alert = await this.alertCtrl.create({
header: 'Esporta in PDF',
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
buttons: [
{
text: 'Solo Testo',
handler: () => {
const name = this.savedPlaylistName || 'Playlist';
this.playlistService.printPlaylist(name, this.localSongs, false, transpositions);
}
},
{
text: 'Testo e Accordi',
handler: () => {
const name = this.savedPlaylistName || 'Playlist';
this.playlistService.printPlaylist(name, this.localSongs, true, transpositions);
}
},
{
text: 'Annulla',
role: 'cancel'
}
]
});
await alert.present();
}
}
@@ -0,0 +1,278 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-buttons slot="start">
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons>
<ion-title class="outfit-font">Mio Canto</ion-title>
</ion-toolbar>
</ion-header>
<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="document-text-outline"></ion-icon>
<p>Rilascia l'immagine o il PDF qui per estrarre il testo</p>
</div>
</div>
<div class="propose-container">
<!-- OCR Progress -->
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
<ion-spinner name="crescent"></ion-spinner>
<div class="progress-info">
<p>Elaborazione in corso...</p>
<ion-progress-bar [value]="ocrProgress" color="secondary"></ion-progress-bar>
</div>
</div>
<!-- Segment Control for mobile layout -->
<ion-segment [(ngModel)]="activeTab" class="custom-mobile-segment ion-margin-bottom" mode="md">
<ion-segment-button value="editor">
<ion-label>Editor</ion-label>
</ion-segment-button>
<ion-segment-button value="preview">
<ion-label>Anteprima</ion-label>
</ion-segment-button>
</ion-segment>
<div class="editor-preview-split">
<!-- Left Column: Inputs & Editor -->
<div class="editor-column" [class.hide-on-mobile]="activeTab === 'preview'">
<ion-list lines="none" class="input-list">
<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">
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Durata (mm:ss)</ion-label>
<ion-input [(ngModel)]="durata" placeholder="Es: 03:45"></ion-input>
</ion-item>
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">BPM (Tempo)</ion-label>
<ion-input type="number" [(ngModel)]="bpm" placeholder="Es: 120"></ion-input>
</ion-item>
</div>
<div class="category-selectors">
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Momento Liturgico</ion-label>
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
<ion-select-option *ngFor="let lit of sortedLiturgico" [value]="lit.id">
{{ lit.tag_name }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Periodo / Tema</ion-label>
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
<ion-select-option *ngFor="let tem of sortedTematico" [value]="tem.id">
{{ tem.tag_name }}
</ion-select-option>
</ion-select>
</ion-item>
</div>
<!-- TOOLBARS -->
<div class="toolbar-section">
<div class="horizontal-toolbar">
<ng-container *ngFor="let tag of commonTags">
<ion-button size="small" fill="outline" (click)="insertText(tag.start)">
{{ tag.label }}
</ion-button>
<ion-button *ngIf="tag.end" size="small" fill="outline" color="medium" (click)="insertText(tag.end)">
Fine {{ tag.label }}
</ion-button>
</ng-container>
</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" style="display: flex; align-items: center; gap: 8px;">
<span>Editor Testo</span>
<!-- Transpose buttons in left column, above textarea -->
<div class="editor-transpose-group" style="display: flex; align-items: center; gap: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px 6px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); margin-left: 12px;">
<ion-button fill="clear" size="small" (click)="transposeDown()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
<ion-icon slot="icon-only" name="remove-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
<span class="val outfit-font" style="font-size: 0.85rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 24px; text-align: center;">T:{{ transposeAmount > 0 ? '+' : '' }}{{ transposeAmount }}</span>
<ion-button fill="clear" size="small" (click)="transposeUp()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
<ion-icon slot="icon-only" name="add-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
</div>
<!-- Set default key button -->
<ion-button *ngIf="transposeAmount !== 0" fill="outline" color="secondary" size="small" (click)="setDefaultTonalita()" style="margin: 0 0 0 8px; font-size: 10px; font-weight: 700; height: 24px; --border-radius: 6px;">
Imposta tonalità di default
</ion-button>
</div>
<div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica">
<ion-icon name="arrow-undo-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="deduceChords()" [disabled]="!content" title="Deduci accordi per le altre strofe">
<ion-icon name="musical-notes-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="takePhoto()" title="Scatta foto">
<ion-icon name="camera-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF">
<ion-icon name="document-attach-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="toggleRawPasteMode()" [color]="isRawPasteModeActive ? 'warning' : 'medium'" [title]="isRawPasteModeActive ? 'Incolla Raw: Attivo (il testo incollato non verrà filtrato)' : 'Attiva Incolla Raw (incolla senza filtri)'" style="font-size: 11px; font-weight: 800; font-family: 'Outfit', sans-serif;">
RAW
</ion-button>
</div>
</div>
<div class="editor-textarea-container">
<!-- Input textarea -->
<textarea
#nativeTextarea
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
(paste)="onPaste($event)"
spellcheck="false"
autocapitalize="none"
autocomplete="off"
autocorrect="off">
</textarea>
</div>
</div>
<ion-item class="custom-input-item">
<ion-label position="stacked">Autore</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
</ion-item>
<!-- YouTube Link and Player Section -->
<div class="custom-input-item youtube-section">
<ion-label position="stacked" style="color: #64ffda; font-family: 'Outfit', sans-serif; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">Link YouTube & Audio Player</ion-label>
<div class="youtube-input-row" style="display: flex; align-items: center; gap: 8px; margin-top: 6px;">
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube" style="flex: 1;"></ion-input>
<ion-button fill="solid" color="secondary" size="small" [disabled]="!youtubeLink" (click)="loadEditorAudio()" style="margin: 0; font-weight: 700;">
Carica
</ion-button>
</div>
<!-- Small Player controls when playing/loaded -->
<div class="editor-mini-player" *ngIf="isAudioLoaded()" style="display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 36px; width: 36px;">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'" style="font-size: 1.6rem;"></ion-icon>
</ion-button>
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: right;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
<ion-range
[min]="0"
[max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)"
color="secondary"
style="margin: 0; padding: 0 4px; flex: 1; --bar-height: 4px; --knob-size: 12px;">
</ion-range>
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: left;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
</div>
</div>
</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" [class.hide-on-mobile]="activeTab === 'editor'">
<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" style="margin: 0;">
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
</ion-button>
</div>
<div class="preview-body">
<div class="preview-song-header">
<h2 class="preview-song-title">{{ title || 'Titolo del Canto' }}</h2>
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
</div>
<div class="preview-lyrics-container">
<div class="lyrics-view">
<div *ngFor="let section of parsedSections"
class="section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="section-label verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines" class="lyric-line">
<!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
</div>
</div>
</div>
<div class="preview-empty" *ngIf="!content">
Il testo formattato apparirà qui mentre scrivi...
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden inputs -->
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
</ion-content>
@@ -0,0 +1,845 @@
:host {
--ion-background-color: #1a1a2e !important;
--ion-text-color: #ffffff !important;
--ion-item-background: transparent !important;
ion-input, ion-textarea, ion-select {
--color: #ffffff !important;
color: #ffffff !important;
--placeholder-color: rgba(255, 255, 255, 0.5) !important;
}
}
body.high-contrast :host {
--ion-background-color: #ffffff !important;
--ion-text-color: #000000 !important;
--ion-item-background: #ffffff !important;
ion-input, ion-textarea, ion-select {
--color: #000000 !important;
color: #000000 !important;
--placeholder-color: rgba(0, 0, 0, 0.6) !important;
}
/* Labels must be black, not teal */
ion-label {
color: #000000 !important;
}
/* Variations container layout in high contrast */
.variations-container {
background: #f5f5f5 !important;
border: 1px solid #555555 !important;
}
}
body.high-contrast :host ::ng-deep {
/* Default styles for high contrast buttons (no border by default, e.g. for fill="clear") */
ion-button {
--background: transparent !important;
--color: #000000 !important;
--color-activated: #000000 !important;
--color-focused: #000000 !important;
--color-hover: #000000 !important;
--border-color: transparent !important;
--border-width: 0px !important;
border: none !important;
font-weight: 700 !important;
}
/* Outlined buttons (tags, chords, action buttons) in high contrast get single gray border */
ion-button[fill="outline"],
.main-chords-toolbar ion-button,
.variations-toolbar ion-button {
--background: #ffffff !important;
--border-color: #555555 !important;
--border-width: 1px !important;
--border-style: solid !important;
}
/* Target specific Light-colored outlined buttons (inactive main chords) to overpower Ionic classes */
ion-button.ion-color-light,
.main-chords-toolbar ion-button.ion-color-light,
.variations-toolbar ion-button.ion-color-light {
--background: #ffffff !important;
--color: #000000 !important;
--color-activated: #000000 !important;
--color-focused: #000000 !important;
--color-hover: #000000 !important;
--border-color: #555555 !important;
--border-width: 1px !important;
--border-style: solid !important;
color: #000000 !important;
border: none !important;
/* OVERRIDE IONIC COLOR BASE FOR COLOR="LIGHT" OUTLINE BUTTONS */
--ion-color-base: #000000 !important;
--ion-color-base-rgb: 0, 0, 0 !important;
}
/* Solid buttons (active chords, save button) in high contrast */
ion-button.button-solid,
ion-button[fill="solid"],
.send-btn {
--background: var(--ion-color-secondary) !important;
--color: #ffffff !important;
--color-activated: #ffffff !important;
--color-focused: #ffffff !important;
--color-hover: #ffffff !important;
--border-color: #555555 !important;
--border-width: 1px !important;
--border-style: solid !important;
border: none !important;
font-weight: 700 !important;
}
/* Textarea inside high contrast must have white background and black text */
.editor-textarea-container {
background: #ffffff !important;
border: 2px solid #000000 !important;
textarea {
color: #000000 !important;
caret-color: #000000 !important;
}
}
/* Force background of inputs and items to be white with single dark gray border in high contrast */
.custom-input-item, .select-item {
--background: #ffffff !important;
border: 1px solid #555555 !important;
}
}
.bg-gradient {
--background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.propose-container {
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.ocr-progress-card {
background: rgba(var(--ion-color-secondary-rgb), 0.15);
border: 1px solid var(--ion-color-secondary);
border-radius: 16px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
ion-spinner {
color: var(--ion-color-secondary);
width: 32px;
height: 32px;
}
.progress-info {
flex: 1;
p {
margin: 0;
font-size: 15px;
font-weight: 600;
color: white;
&.small {
font-size: 11px;
color: rgba(255,255,255,0.6);
margin-top: 2px;
}
}
ion-progress-bar {
margin-top: 12px;
height: 6px;
border-radius: 3px;
}
}
}
.toolbar-section {
margin-bottom: 12px;
.toolbar-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 6px;
padding-left: 4px;
font-weight: 700;
}
}
.horizontal-toolbar {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 10px;
scrollbar-width: thin;
&::-webkit-scrollbar {
height: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
ion-button {
--border-radius: 10px;
margin: 0;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
}
}
.chords-toolbar {
ion-button {
--background: rgba(255, 255, 255, 0.1);
--color: #ffffff;
border: 1px solid rgba(255,255,255,0.1);
&:active {
--background: var(--ion-color-secondary);
}
}
}
.editor-wrapper {
margin-bottom: 20px;
background: #000000;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: inset 0 2px 10px rgba(0,0,0,0.5);
.editor-header {
background: rgba(255,255,255,0.05);
padding: 4px 8px 4px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.1);
.editor-title-group {
span {
font-size: 11px;
font-weight: 800;
color: var(--ion-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.editor-actions {
display: flex;
gap: 0;
ion-button {
--color: rgba(255,255,255,0.6);
margin: 0;
height: 36px;
&[disabled] {
opacity: 0.3;
}
ion-icon {
font-size: 18px;
}
}
}
}
&.hc {
border: 2px solid #000000;
background: #ffffff;
.editor-header {
background: #f0f0f0;
border-bottom: 2px solid #000000;
span { color: #000000; }
small { color: #333; font-weight: 600; }
ion-button {
--color: #000000 !important;
}
}
.editor-textarea-container {
background: #ffffff;
textarea {
color: #000000;
caret-color: #000000;
}
}
}
}
:host ::ng-deep {
.editor-textarea-container {
position: relative;
width: 100%;
height: 450px;
background: #111111;
border-radius: 0 0 14px 14px;
overflow: hidden;
box-sizing: border-box;
textarea {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0 !important;
padding: 16px !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
font-size: 1.05rem !important;
line-height: 1.6 !important;
color: #ffffff !important;
background: transparent !important;
width: 100%;
height: 100%;
box-sizing: border-box !important;
resize: none;
overflow-y: auto !important;
caret-color: #64ffda !important;
border: none !important;
outline: none !important;
}
}
.category-selectors {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.select-item {
--background: rgba(255, 255, 255, 0.08) !important;
--border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
ion-select {
width: 100%;
--padding-start: 0;
--color: #ffffff !important;
color: #ffffff !important;
font-size: 14px;
}
}
.custom-input-item {
--background: rgba(255, 255, 255, 0.08) !important;
--border-radius: 12px;
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
ion-label {
color: #64ffda !important; /* High-contrast teal label */
font-family: 'Outfit', sans-serif;
font-weight: 700;
margin-bottom: 6px !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 1px;
}
ion-input {
--color: #ffffff !important;
color: #ffffff !important;
font-weight: 500;
}
}
.textarea-item {
--background: transparent;
--padding-start: 0;
--padding-end: 0;
}
}
.action-buttons {
margin-top: 24px;
margin-bottom: 50px;
}
.send-btn {
--border-radius: 16px;
--background: var(--ion-color-secondary);
font-weight: 700;
height: 60px;
font-size: 16px;
box-shadow: 0 8px 20px rgba(var(--ion-color-secondary-rgb), 0.3);
}
/* INTERACTIVE CHORDS TOOLBAR */
.main-chords-toolbar {
ion-button {
--border-radius: 8px;
font-weight: 700;
font-size: 13px;
min-width: 44px;
margin: 0;
}
}
.variations-container {
background: rgba(var(--ion-color-secondary-rgb), 0.08);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.25);
border-radius: 12px;
padding: 8px 12px;
margin-top: 8px;
margin-bottom: 12px;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.variations-toolbar {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.variations-label {
font-size: 11px;
font-weight: 700;
color: var(--ion-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
ion-button {
--background: rgba(255, 255, 255, 0.12);
--color: #ffffff;
--border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
margin: 0;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
height: 28px;
&:active {
--background: var(--ion-color-secondary);
}
}
}
/* 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;
}
.lyrics-view {
max-width: 900px;
margin-left: 0;
text-align: left;
}
.section {
margin-bottom: 2rem;
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;
}
.section-label {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 700;
color: var(--ion-color-secondary);
margin-bottom: 0.5rem;
opacity: 0.7;
letter-spacing: 1px;
}
}
.lyric-line {
margin-bottom: 1rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
min-height: 1.5em;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
letter-spacing: 3px;
}
.chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
margin-right: 0.25em;
&.contiguous-next {
margin-right: 0 !important;
}
.chord {
font-size: 0.75em;
font-weight: 700;
color: var(--ion-color-secondary);
height: 1.2em;
margin-bottom: -0.2em;
padding-right: 0.25em;
}
.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-transpose-group {
border-color: #000000 !important;
span {
color: #000000 !important;
}
ion-button {
--color: #000000 !important;
}
}
}
.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 {
.section {
&.chorus {
background: #f5f5f5;
border-left: 3px solid #000000;
}
.section-label {
color: #000000;
}
}
.lyric-line {
color: #000000;
}
.chord-segment {
.chord {
color: #000000;
text-decoration: underline;
font-weight: 800;
}
}
.preview-empty {
color: #666666;
}
}
}
}
}
.youtube-section {
display: flex;
flex-direction: column;
.youtube-input-row {
ion-button {
height: 38px;
}
}
}
/* CUSTOM STYLES FOR INTUITIVE MOBILE RESPONSIVENESS */
.custom-mobile-segment {
display: none;
--background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 16px;
ion-segment-button {
--color: rgba(255, 255, 255, 0.6);
--color-checked: #ffffff;
--indicator-color: var(--ion-color-secondary);
--border-radius: 8px;
font-weight: 700;
font-family: 'Outfit', sans-serif;
}
}
body.high-contrast :host ::ng-deep {
.custom-mobile-segment {
--background: #f0f0f0 !important;
background: #f0f0f0 !important;
border: 2px solid #000000 !important;
ion-segment-button {
--color: #333333 !important;
--color-checked: #000000 !important;
--indicator-color: #000000 !important;
}
}
}
@media (max-width: 991px) {
.custom-mobile-segment {
display: flex;
}
.hide-on-mobile {
display: none !important;
}
.horizontal-toolbar {
flex-wrap: wrap;
overflow-x: visible;
padding-bottom: 0;
ion-button {
flex-shrink: 1;
}
}
}
@media (max-width: 576px) {
:host ::ng-deep .category-selectors {
grid-template-columns: 1fr;
gap: 8px;
}
.editor-wrapper {
.editor-header {
padding: 6px 8px;
flex-wrap: wrap;
.editor-title-group {
width: 100%;
margin-bottom: 6px;
padding-left: 8px;
}
.editor-actions {
width: 100%;
display: flex;
justify-content: space-between;
ion-button {
flex: 1;
height: 40px;
margin: 0;
}
}
}
}
}
@@ -0,0 +1,151 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProposeCantoPage } from './propose-canto.page';
import { ToastController, PopoverController, NavController, AngularDelegate, AlertController } from '@ionic/angular';
import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, Router } from '@angular/router';
import { LyricsParserService } from '../../services/lyrics-parser.service';
import { of } from 'rxjs';
describe('ProposeCantoPage', () => {
let component: ProposeCantoPage;
let fixture: ComponentFixture<ProposeCantoPage>;
beforeEach(() => {
const cantiServiceMock = {
indiceLiturgico: () => [],
indiceTematico: () => [],
canti: () => []
};
const myCantiServiceMock = {
myCanti: () => []
};
const playlistServiceMock = {
remoteCustomSongs: () => [],
activePlaylistId: () => null
};
const themeServiceMock = {
highContrast: () => false
};
const activatedRouteMock = {
queryParams: of({})
};
const routerMock = {
navigate: jasmine.createSpy('navigate')
};
const lyricsParserMock = {
parseAccordi: () => []
};
const navCtrlMock = {};
const toastControllerMock = {};
const popoverControllerMock = {};
const alertControllerMock = {};
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 },
{ provide: AlertController, useValue: alertControllerMock }
]
});
fixture = TestBed.createComponent(ProposeCantoPage);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('convertEnglishChordToItalian', () => {
it('should convert simple English chords to Italian', () => {
expect(component.convertEnglishChordToItalian('C')).toBe('DO');
expect(component.convertEnglishChordToItalian('D')).toBe('RE');
expect(component.convertEnglishChordToItalian('E')).toBe('MI');
expect(component.convertEnglishChordToItalian('F')).toBe('FA');
expect(component.convertEnglishChordToItalian('G')).toBe('SOL');
expect(component.convertEnglishChordToItalian('A')).toBe('LA');
expect(component.convertEnglishChordToItalian('B')).toBe('SI');
});
it('should convert English chords with accidentals and modifiers', () => {
expect(component.convertEnglishChordToItalian('C#m7')).toBe('DO#m7');
expect(component.convertEnglishChordToItalian('Bb')).toBe('SIb');
expect(component.convertEnglishChordToItalian('F#m')).toBe('FA#m');
expect(component.convertEnglishChordToItalian('Faug')).toBe('FAaug');
expect(component.convertEnglishChordToItalian('Fadd9')).toBe('FAadd9');
});
it('should keep Italian chords unchanged', () => {
expect(component.convertEnglishChordToItalian('DO')).toBe('DO');
expect(component.convertEnglishChordToItalian('RE#m7')).toBe('RE#m7');
expect(component.convertEnglishChordToItalian('FA#')).toBe('FA#');
expect(component.convertEnglishChordToItalian('SIb')).toBe('SIb');
});
it('should handle slash chords correctly', () => {
expect(component.convertEnglishChordToItalian('C/E')).toBe('DO/MI');
expect(component.convertEnglishChordToItalian('D/F#')).toBe('RE/FA#');
expect(component.convertEnglishChordToItalian('F#m7/A#')).toBe('FA#m7/LA#');
});
});
describe('sanitizeOcrChord', () => {
it('should convert H to # when following a chord root', () => {
expect(component.sanitizeOcrChord('CH')).toBe('C#');
expect(component.sanitizeOcrChord('DHm7')).toBe('D#m7');
expect(component.sanitizeOcrChord('FH#')).toBe('F#');
expect(component.sanitizeOcrChord('FAH')).toBe('FA#');
expect(component.sanitizeOcrChord('SOLH7')).toBe('SOL#7');
expect(component.sanitizeOcrChord('FH#H')).toBe('F#');
expect(component.sanitizeOcrChord('FH#H/AH#')).toBe('F#/A#');
expect(component.sanitizeOcrChord('C#H-')).toBe('C#-');
expect(component.sanitizeOcrChord('G#H#')).toBe('G#');
});
it('should convert 0 to O for DO and SOL roots', () => {
expect(component.sanitizeOcrChord('D0')).toBe('DO');
expect(component.sanitizeOcrChord('D0#')).toBe('DO#');
expect(component.sanitizeOcrChord('S0L')).toBe('SOL');
});
it('should handle slash chords with sanitization', () => {
expect(component.sanitizeOcrChord('CH/EH')).toBe('C#/E#');
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
});
});
describe('isChordWord', () => {
it('should identify Italian and English chords correctly based on mode', () => {
expect(component.isChordWord('DO', true)).toBe(true);
expect(component.isChordWord('DO', false)).toBe(true);
expect(component.isChordWord('C', true)).toBe(false); // English chord in Italian notation -> false
expect(component.isChordWord('C', false)).toBe(true); // English chord in English notation -> true
expect(component.isChordWord('Lan', true)).toBe(true);
expect(component.isChordWord('Lan', false)).toBe(true);
});
});
describe('isItalianNotation', () => {
it('should detect Italian notation if DO/RE/MI/SOL/SI are present', () => {
const words = [{ text: 'Lan' }, { text: 'FA' }, { text: 'DO' }, { text: 'SOL' }];
expect(component.isItalianNotation(words)).toBe(true);
});
it('should return false if only English-like or non-italian chords are present', () => {
const words = [{ text: 'C' }, { text: 'G' }, { text: 'D' }];
expect(component.isItalianNotation(words)).toBe(false);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SettingsPage } from './settings.page';
const routes: Routes = [
{
path: '',
component: SettingsPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class SettingsPageRoutingModule {}
+17
View File
@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { SettingsPageRoutingModule } from './settings-routing.module';
import { SettingsPage } from './settings.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
SettingsPageRoutingModule
],
declarations: [SettingsPage]
})
export class SettingsPageModule {}
+507
View File
@@ -0,0 +1,507 @@
<ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-buttons slot="start">
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons>
<ion-title class="outfit-font">Impostazioni</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true" class="bg-gradient">
<div class="settings-container ion-padding">
<!-- PWA Install Group (Android / Chrome custom prompt) -->
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.showInstallButton() && !settingsService.isStandalone()">
<ion-item class="transparent-item" lines="none" button (click)="installApp()">
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold;">Installa Applicazione</h2>
<p class="settings-item-subtitle">Aggiungi {{ appName }} alla schermata home</p>
</ion-label>
<ion-button fill="solid" size="small" color="secondary" slot="end" class="outfit-font" style="--border-radius: 12px; font-weight: bold; text-transform: none; margin: 0;">
Installa
</ion-button>
</ion-item>
</div>
<!-- PWA Install Group (iOS Safari Instructions) -->
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.isIos() && !settingsService.isStandalone()">
<ion-item class="transparent-item" lines="none" button (click)="toggleIosInstructions()">
<ion-icon name="logo-apple" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Installazione su iPhone / iPad</h2>
<p class="settings-item-subtitle">Scopri come aggiungere l'app sulla home</p>
</ion-label>
<ion-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
</ion-item>
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid var(--ion-border-color, rgba(255,255,255,0.08)); background: rgba(var(--ion-text-color-rgb, 255,255,255), 0.02);">
<p class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0 0 12px 0; line-height: 1.4; opacity: 0.9;">
Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
</p>
<ol class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0; padding-left: 20px; line-height: 1.6; opacity: 0.9;">
<li style="margin-bottom: 8px;">
Tocca il pulsante di <strong>Condivisione</strong> <ion-icon name="share-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon> nella barra di navigazione inferiore di Safari.
</li>
<li style="margin-bottom: 8px;">
Scorri il menu verso il basso e seleziona <strong>Aggiungi alla schermata Home</strong> <ion-icon name="add-circle-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon>.
</li>
<li>
Conferma toccando <strong>Aggiungi</strong> in alto a destra. L'icona dell'app apparirà sul tuo schermo!
</li>
</ol>
</div>
</div>
<!-- PWA Install Group (Desktop Fallback Instructions) -->
<div class="settings-group glass ion-margin-bottom" *ngIf="!settingsService.showInstallButton() && !settingsService.isStandalone() && !settingsService.isIos()">
<ion-item class="transparent-item" lines="none">
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font" style="white-space: normal;">
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold; margin-bottom: 6px;">Come installare l'applicazione</h2>
<p class="settings-item-subtitle" style="font-size: 0.85rem; line-height: 1.45; opacity: 0.9; color: var(--ion-text-color); margin: 0;">
Se usi <strong>Chrome / Edge / Brave</strong>: puoi installarla cliccando sull'icona di installazione <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">⊕</span> o <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">📥</span> che appare a destra nella barra degli indirizzi del browser.<br><br>
Se usi <strong>Safari (su Mac)</strong>: clicca sul menu <strong>File</strong> in alto e seleziona <strong>Aggiungi al Dock...</strong>.
</p>
</ion-label>
</ion-item>
</div>
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none">
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Schermo Intero Browser</h2>
<p class="settings-item-subtitle">Espande l'app a tutto schermo</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Alto Contrasto</h2>
<p class="settings-item-subtitle">Colori più netti per il sole</p>
</ion-label>
<ion-toggle slot="end" [checked]="themeService.highContrast()" (ionChange)="themeService.toggleContrast()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="play-forward-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Avanzamento Automatico</h2>
<p class="settings-item-subtitle">Passa al canto successivo</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.autoAdvance()" (ionChange)="settingsService.toggleAutoAdvance()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="create-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Editor e Canti Personali</h2>
<p class="settings-item-subtitle">Abilita aggiunta e gestione "Miei"</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.showEditor()" (ionChange)="settingsService.toggleEditor()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="pricetags-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Visualizza tag</h2>
<p class="settings-item-subtitle">Mostra filtri sotto autore nella lista canti</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.showTagsInList()" (ionChange)="settingsService.toggleShowTagsInList()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="calendar-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Visualizza data update</h2>
<p class="settings-item-subtitle">Mostra data aggiornamento nella lista canti</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="musical-notes-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Visualizza durata, bpm e tonalità</h2>
<p class="settings-item-subtitle">Mostra queste info sotto autore / titolo</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.showDurationBpmTonality()" (ionChange)="settingsService.toggleShowDurationBpmTonality()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Autoscroll standard</h2>
<p class="settings-item-subtitle">Abilita 3 tasti di autoscroll nel canto</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="eye-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Autoscroll visuale</h2>
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.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 class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
<ion-icon name="car-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Schermo nero durante l'ascolto</h2>
<p class="settings-item-subtitle">Se attiva la riproduzione in home, mostra uno schermo nero per risparmiare batteria. Tocca per disattivare.</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.carModeBlackScreen()" (ionChange)="settingsService.toggleCarModeBlackScreen()" color="secondary"></ion-toggle>
</ion-item>
</div>
<!-- Comunità -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none">
<ion-icon name="people-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Comunità</h2>
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="onComunitaToggleChange($event)" color="secondary"></ion-toggle>
</ion-item>
<!-- Community Details/Form when enabled -->
<div class="ion-padding-horizontal ion-padding-bottom" *ngIf="settingsService.comunitaEnabled()" style="border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px;">
<div *ngIf="comunitaService.comunitaCode()" class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between;">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Comunità Attiva</div>
<div style="font-size: 0.9rem; font-weight: 500; color: var(--ion-text-color); overflow: hidden; text-overflow: ellipsis;">{{ comunitaService.comunitaNome() }}</div>
<div style="font-size: 0.75rem; opacity: 0.7; color: var(--ion-text-color); margin-top: 2px;">Codice: {{ comunitaService.comunitaCode() }}</div>
</div>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<ion-button fill="clear" size="small" color="secondary" (click)="editComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
<ion-icon name="create-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="danger" (click)="removeComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
<ion-icon name="trash-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
</ion-button>
</div>
</div>
<div *ngIf="!comunitaService.comunitaCode()" style="display: flex; flex-direction: column; gap: 12px;">
<p class="settings-item-subtitle outfit-font" style="margin: 0; font-size: 0.8rem; opacity: 0.85;">
Inserisci il codice parrocchiale/comunità per scaricare i canti e le scalette dedicate:
</p>
<div style="display: flex; gap: 10px; align-items: center;">
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
<input type="text"
#comunitaCodeInput
placeholder="il codice di 6 cifre"
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>
<!-- Importa Playlist -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none" button (click)="importPlaylistViaQr()">
<ion-icon name="qr-code-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Importa Playlist via QR</h2>
<p class="settings-item-subtitle">Inquadra il QR di una playlist condivisa per importarla</p>
</ion-label>
<ion-icon name="chevron-forward-outline" slot="end" color="medium" style="font-size: 1rem;"></ion-icon>
</ion-item>
</div>
<!-- Invio Dati Statistici -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none">
<ion-icon name="analytics-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Invio dati statistici</h2>
<p class="settings-item-subtitle">Invia statistiche di utilizzo e tonalità</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.invioDatiStatistici()" (ionChange)="settingsService.toggleInvioDatiStatistici()" color="secondary"></ion-toggle>
</ion-item>
</div>
<!-- Display Mode Section -->
<div class="settings-group glass ion-margin-bottom">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title">
Modalità Visualizzazione
</h2>
</div>
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
<div class="filter-btn glass"
[class.active-btn]="!settingsService.showChordsDefault()"
(click)="onModeChange({detail: {value: 'text'}})">
<span>Solo Testo</span>
</div>
<div class="filter-btn glass"
[class.active-btn]="settingsService.showChordsDefault()"
(click)="onModeChange({detail: {value: 'chords'}})">
<span>Accordi</span>
</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">
<h2 class="outfit-font settings-group-title">
Notazione Accordi Preferita
</h2>
</div>
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
<div class="filter-btn glass"
[class.active-btn]="settingsService.chordNotationPreference() === 'diesis'"
(click)="settingsService.setChordNotationPreference('diesis')">
<span>Diesis (#)</span>
</div>
<div class="filter-btn glass"
[class.active-btn]="settingsService.chordNotationPreference() === 'bemolle'"
(click)="settingsService.setChordNotationPreference('bemolle')">
<span>Bemolle (b)</span>
</div>
</div>
</div>
</div>
<!-- Camera Tracking Mode Section -->
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.enableVisualAutoscroll()">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title">
Modalità Tracciamento Fotocamera (Karaoke)
</h2>
</div>
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
<div class="filter-btn glass"
[class.active-btn]="settingsService.cameraTrackingMode() === 'distance'"
(click)="settingsService.setCameraTrackingMode('distance')">
<span>Distanza Viso (Avanti / Dietro)</span>
</div>
<div class="filter-btn glass"
[class.active-btn]="settingsService.cameraTrackingMode() === 'tilt'"
(click)="settingsService.setCameraTrackingMode('tilt')">
<span>Inclinazione Testa</span>
</div>
</div>
<p class="settings-item-subtitle outfit-font" style="margin: 8px 12px 0 12px; font-size: 0.78rem; opacity: 0.8; line-height: 1.4;">
<span *ngIf="settingsService.cameraTrackingMode() === 'distance'">
💡 <strong>Distanza Viso:</strong> avvicinati allo schermo per avanzare nel testo, allontanati per tornare indietro. Tocca la percentuale nel player per calibrare all'istante la tua distanza attuale al 100%.
</span>
<span *ngIf="settingsService.cameraTrackingMode() === 'tilt'">
💡 <strong>Inclinazione:</strong> piega la testa a destra o a sinistra per avanzare o tornare indietro.
</span>
</p>
</div>
</div>
<!-- Legenda Pulsanti Canto -->
<div class="settings-group glass ion-margin-top">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title">
Legenda Pulsanti del Canto
</h2>
</div>
<div class="legend-content ion-padding-horizontal ion-padding-bottom">
<p class="legend-intro outfit-font">
All'interno della schermata di lettura del canto trovi vari pulsanti per controllare la visualizzazione e lo scorrimento:
</p>
<div class="legend-list">
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="videocam" color="success"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
<p class="outfit-font">
Attiva lo scorrimento visuale intelligente tramite fotocamera frontale (avvicinandoti/allontanandoti o inclinando il capo in base alla modalità selezionata).
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="musical-note" color="secondary"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Trasposizione Tonalità (+ / -)</h4>
<p class="outfit-font">
Disponibile in modalità "Accordi". Alza o abbassa la tonalità del canto di semitono in semitono, ricalcolando gli accordi all'istante.
</p>
</div>
</div>
<div class="legend-item">
<div style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
<div class="legend-icon-wrapper">
<ion-icon name="text-outline" color="medium"></ion-icon>
</div>
<div class="legend-icon-wrapper">
<ion-icon name="musical-notes-outline" color="secondary"></ion-icon>
</div>
</div>
<div class="legend-text" style="padding-top: 6px;">
<h4 class="outfit-font">Testo / Accordi (icona in alto a destra)</h4>
<p class="outfit-font">
Alterna la visualizzazione tra solo testo e testo con accordi. L'icona con la <strong>T</strong> (grigia) indica la modalità testo; l'icona con le note musicali (arancione) indica la modalità accordi. Un tocco cambia modalità all'istante.
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Riavvia Canto</h4>
<p class="outfit-font">
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento dello scroll visuale.
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="chevron-back" color="secondary"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Navigazione Righe</h4>
<p class="outfit-font">
Permette di avanzare o tornare indietro manualmente tra le righe del testo del canto.
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="add-circle-outline" color="secondary"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Zoom Caratteri</h4>
<p class="outfit-font">
Ingrandisce o rimpicciolisce il testo e gli accordi (puoi usare anche il gesto "pinch-to-zoom" sullo schermo).
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="logo-youtube" color="danger"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">YouTube Esterno</h4>
<p class="outfit-font">
Apre la traccia o il video ufficiale del canto direttamente sull'applicazione esterna di YouTube (quando online).
</p>
</div>
</div>
</div>
</div>
</div>
<div class="credits-footer ion-padding-top ion-text-center">
<p class="outfit-font email-text">
info@canticristiani.it
</p>
</div>
<div class="ion-padding-top ion-text-center">
<p class="outfit-font settings-footer-text">
Le preferenze vengono salvate automaticamente.
</p>
</div>
</div>
</ion-content>
+326
View File
@@ -0,0 +1,326 @@
.bg-gradient {
--background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
}
.outfit-font {
font-family: 'Outfit', sans-serif;
}
.settings-container {
max-width: 600px;
margin: 0 auto;
}
.settings-group {
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.transparent-item {
--background: transparent;
--color: white;
--padding-start: 16px;
--inner-padding-end: 16px;
}
.settings-item-title {
color: white;
font-weight: 600;
margin: 0;
}
.settings-item-subtitle {
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
.settings-group-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--ion-color-secondary);
margin: 0;
}
.sub-group-title {
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.playlist-item {
--min-height: 44px;
background: rgba(255, 255, 255, 0.03) !important;
margin: 4px 8px;
border-radius: 8px;
.settings-item-title {
font-size: 0.9rem;
}
}
ion-radio {
--color: rgba(255, 255, 255, 0.4);
--color-checked: var(--ion-color-secondary);
font-size: 1rem;
width: 100%;
ion-label {
color: white;
}
}
.compact-mode {
.filter-btn {
flex: 1;
justify-content: center;
}
}
.group-header {
margin-bottom: 8px;
}
.sync-info {
margin-top: 4px;
padding-left: 56px;
p {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
strong {
color: var(--ion-color-secondary);
}
}
}
.sync-progress {
height: 4px;
background: rgba(255, 255, 255, 0.1);
width: 100%;
.progress-bar {
height: 100%;
background: var(--ion-color-secondary);
transition: width 0.3s ease;
}
}
.settings-footer-text {
color: rgba(255, 255, 255, 0.3);
font-size: 0.8rem;
}
// High contrast overrides
:host-context(body.high-contrast) {
.settings-item-title {
color: black !important;
}
.settings-item-subtitle {
color: rgba(0, 0, 0, 0.6) !important;
}
.settings-footer-text {
color: rgba(0, 0, 0, 0.4) !important;
}
.sub-group-title {
color: rgba(0, 0, 0, 0.7) !important;
}
.sync-info p {
color: rgba(0, 0, 0, 0.7) !important;
}
ion-label {
color: black !important;
}
ion-radio {
--color: rgba(0, 0, 0, 0.3);
color: black !important;
ion-label {
color: black !important;
}
}
}
.credits-footer {
margin-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 24px;
.credits-text {
font-size: 1rem;
font-weight: 600;
color: var(--ion-color-secondary);
line-height: 1.6;
}
.email-text {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.5);
margin-top: 8px;
}
}
:host-context(body.high-contrast) {
.credits-footer {
border-top-color: rgba(0, 0, 0, 0.2);
.credits-text {
color: black !important;
}
.email-text {
color: rgba(0, 0, 0, 0.6) !important;
}
}
}
.select-item-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.select-label-row {
display: flex;
align-items: center;
gap: 12px;
}
.select-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.select-text-group {
display: flex;
flex-direction: column;
.settings-item-title {
font-size: 1rem !important;
font-weight: 600 !important;
line-height: 1.2;
margin: 0;
}
.settings-item-subtitle {
font-size: 0.85rem !important;
margin-top: 2px !important;
}
}
.select-box-wrapper {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 4px 12px;
transition: background 0.2s ease, border-color 0.2s ease;
&:active {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
}
.custom-select-block {
--placeholder-color: rgba(255, 255, 255, 0.4);
--placeholder-opacity: 1;
--color: var(--ion-color-secondary);
color: var(--ion-color-secondary);
font-family: 'Outfit', sans-serif;
font-weight: 500;
width: 100%;
font-size: 0.95rem;
&::part(placeholder), &::part(text) {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
:host-context(body.high-contrast) {
.select-box-wrapper {
background: rgba(0, 0, 0, 0.05) !important;
border-color: rgba(0, 0, 0, 0.3) !important;
}
.custom-select-block {
--color: black !important;
color: black !important;
}
.legend-content {
.legend-intro {
color: #333333 !important;
}
.legend-item {
.legend-icon-wrapper {
background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid #cccccc;
}
.legend-text {
h4 {
color: #000000 !important;
}
p {
color: #222222 !important;
}
}
}
}
}
.legend-content {
.legend-intro {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 16px;
line-height: 1.4;
}
.legend-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.legend-item {
display: flex;
align-items: flex-start;
gap: 12px;
.legend-icon-wrapper {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
ion-icon {
font-size: 1.2rem;
}
}
.legend-text {
flex: 1;
h4 {
margin: 0 0 4px 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--ion-color-secondary);
}
p {
margin: 0;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.6);
line-height: 1.4;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More