Compare commits
25 Commits
adc3d510ad
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 171780bf87 | |||
| a15a6c2139 | |||
| 0069c1a55f | |||
| ad9286002b | |||
| 73097bf9bd | |||
| 3c91355e10 | |||
| 94d014f815 | |||
| 9f9b164937 | |||
| 333858bf48 | |||
| 2607c9c1a8 | |||
| 68d455289d | |||
| d4a52a8fbf | |||
| 6ed90ffff1 | |||
| b0bb5735c8 | |||
| 93385097f2 | |||
| 97a11d5074 | |||
| 2d23dcae83 | |||
| 1b6e8a1832 | |||
| b4b7e10c5f | |||
| d023bb7d3f | |||
| 4d1883a45d | |||
| e7e43468b3 | |||
| 92e955915e | |||
| 960c73fbd0 | |||
| b220167794 |
@@ -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.
|
||||
@@ -1,4 +1,9 @@
|
||||
FTP_PASSWORD=cantiDavid@72
|
||||
FTP_THREADS=30
|
||||
FTP_THREADS=100
|
||||
BASE_HREF=/ionic/
|
||||
CONTACT_EMAIL=info@canticristiani.it
|
||||
API_AUTH_USER=canti
|
||||
API_AUTH_PASS=antani2026
|
||||
VPS_HOST=185.193.67.105
|
||||
VPS_USER=root
|
||||
VPS_PATH=/var/docker/canticristiani-pwa/html
|
||||
|
||||
+23
-10
@@ -29,25 +29,38 @@ if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Rotazione file su FTP ---
|
||||
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" \
|
||||
# --- 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
|
||||
|
||||
# --- Upload via FTP ---
|
||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento completato con successo!"
|
||||
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
|
||||
|
||||
+18
-6
@@ -12,7 +12,7 @@ fi
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
SOURCE_URL="http://185.193.67.105:3000/cantiletture.json"
|
||||
SOURCE_URL="https://api.canticristiani.it/cantiletture.json"
|
||||
REMOTE_PATH="api/cantiletture.json"
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
@@ -23,20 +23,32 @@ fi
|
||||
# --- Download del file ---
|
||||
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
|
||||
TEMP_FILE=$(mktemp)
|
||||
if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
||||
if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
||||
echo "❌ Errore nel download dei dati."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Upload via FTP ---
|
||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento completato con successo!"
|
||||
# --- 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
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "16kb",
|
||||
"maximumError": "25kb"
|
||||
"maximumWarning": "32kb",
|
||||
"maximumError": "48kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
echo "Creazione del backup del repository Git locale..."
|
||||
zip -r git_backup.zip . -x "node_modules/*" ".angular/*" "www/*" "platforms/*" "plugins/*" ".ionic/*" "dist/*" ".nx/*" "*/.DS_Store" "git_backup.zip" "backup.sh"
|
||||
echo "Backup completato! Creato il file git_backup.zip."
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id_comunita": "123456",
|
||||
"nome_comunita": "Comunità Test 123456",
|
||||
"canti": [378, 379, 380]
|
||||
}
|
||||
@@ -17,6 +17,28 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
|
||||
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||
echo "🏷️ Versione aggiornata a: $VERSION"
|
||||
|
||||
# --- Caricamento variabili d'ambiente ---
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
# --- Configurazione Email e API Parametriche ---
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
||||
const apiUser = process.env.API_AUTH_USER || 'canti';
|
||||
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
|
||||
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
|
||||
if (fs.existsSync(file)) {
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
|
||||
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
|
||||
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
|
||||
fs.writeFileSync(file, content, 'utf8');
|
||||
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
|
||||
}
|
||||
});
|
||||
"
|
||||
|
||||
# 2. Build dell'applicazione
|
||||
echo "📦 Compilazione in corso (Production Build)..."
|
||||
@@ -37,6 +59,11 @@ fi
|
||||
|
||||
echo "✅ Build completata con successo in: $DIST_PATH"
|
||||
|
||||
# --- Genera version.json per il polling PWA ---
|
||||
BUILD_TIMESTAMP=$(date +%s)000
|
||||
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
|
||||
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||
|
||||
# 2.5 In locale usiamo la root
|
||||
echo "📁 Build pronta nella root..."
|
||||
# Nessuna sottocartella ionic necessaria in locale
|
||||
|
||||
+23
-74
@@ -8,17 +8,13 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Configurazione Parallelismo ---
|
||||
THREADS=${1:-${FTP_THREADS:-30}}
|
||||
# --- Configurazione Target ---
|
||||
TARGET="contabo"
|
||||
echo "🎯 Target di deploy: $TARGET"
|
||||
|
||||
# --- Configurazione FTP per ROOT www.canticristiani.it ---
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
REMOTE_DIR="" # Carica nella root della cartella FTP
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
||||
# --- 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
|
||||
|
||||
@@ -27,16 +23,20 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
|
||||
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||
echo "🏷️ Versione aggiornata a: $VERSION"
|
||||
|
||||
# --- Configurazione Email Parametrica ---
|
||||
# --- Configurazione Email e API Parametriche ---
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
||||
const apiUser = process.env.API_AUTH_USER || 'canti';
|
||||
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
|
||||
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
|
||||
if (fs.existsSync(file)) {
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
|
||||
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
|
||||
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
|
||||
fs.writeFileSync(file, content, 'utf8');
|
||||
console.log(\`📧 Aggiornata email di contatto in \${file} a: \${envEmail}\`);
|
||||
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
|
||||
}
|
||||
});
|
||||
"
|
||||
@@ -51,68 +51,17 @@ if [ ! -d "www" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Upload via FTP ---
|
||||
echo "🚀 2/2 Caricamento parallelo ($THREADS connessioni) su $FTP_HOST nella ROOT..."
|
||||
cd www
|
||||
# --- Genera version.json per il polling PWA ---
|
||||
BUILD_TIMESTAMP=$(date +%s)000
|
||||
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
|
||||
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||
|
||||
TOTAL_FILES=$(find . -type f | wc -l | xargs)
|
||||
PROGRESS_LOG=$(mktemp)
|
||||
ERROR_LOG=$(mktemp)
|
||||
# --- Upload ---
|
||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||
|
||||
show_progress() {
|
||||
local current=0
|
||||
while [ "$current" -lt "$TOTAL_FILES" ]; do
|
||||
current=$(wc -l < "$PROGRESS_LOG" | xargs)
|
||||
local percent=$((current * 100 / TOTAL_FILES))
|
||||
local bar_size=20
|
||||
local num_hash=$((percent * bar_size / 100))
|
||||
local bar=$(printf "%${num_hash}s" | tr ' ' '#' 2>/dev/null)
|
||||
local spaces=$(printf "%$((bar_size - num_hash))s" | tr ' ' '-')
|
||||
printf "\r[%-20s] %d%% (%d/%d) caricati..." "$bar$spaces" "$percent" "$current" "$TOTAL_FILES"
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
show_progress &
|
||||
BAR_PID=$!
|
||||
|
||||
# Lancio dei job paralleli
|
||||
find . -type f | while read -r file; do
|
||||
REMOTE_FILE_PATH=${file#./}
|
||||
|
||||
# Eseguiamo curl e segniamo SEMPRE il progresso (anche se fallisce)
|
||||
(
|
||||
# Usiamo --retry per gestire errori temporanei di connessione
|
||||
if ! curl -s --retry 3 --retry-delay 1 --connect-timeout 10 -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$file" "ftp://$FTP_HOST/$REMOTE_FILE_PATH"; then
|
||||
echo "$REMOTE_FILE_PATH" >> "$ERROR_LOG"
|
||||
fi
|
||||
echo 1 >> "$PROGRESS_LOG"
|
||||
) &
|
||||
|
||||
# Gestione THREADS
|
||||
while [ $(jobs -r | wc -l) -ge "$THREADS" ]; do
|
||||
sleep 0.05
|
||||
done
|
||||
done
|
||||
|
||||
# Attendi fine caricamenti
|
||||
wait
|
||||
|
||||
# Stop barra
|
||||
sleep 0.5
|
||||
kill $BAR_PID 2>/dev/null
|
||||
echo ""
|
||||
|
||||
# Controllo errori
|
||||
ERRORS=$(wc -l < "$ERROR_LOG" | xargs)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "⚠️ Deploy completato con $ERRORS errori."
|
||||
echo "I seguenti file non sono stati caricati:"
|
||||
cat "$ERROR_LOG"
|
||||
else
|
||||
echo "✅ Deploy completato con successo nella ROOT senza errori!"
|
||||
# --- Allineamento Cantiletture JSON ---
|
||||
if [ -f "./allinealetture.sh" ]; then
|
||||
echo "🔄 Sincronizzazione cantiletture.json su server..."
|
||||
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm "$PROGRESS_LOG" "$ERROR_LOG"
|
||||
printf "L'app è disponibile su https://www.canticristiani.it/\n"
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
# Specifiche Tecniche: Integrazione Servizio `/miei` con PWA Canti
|
||||
|
||||
Questo documento descrive le specifiche dell'endpoint `/miei` implementato sul server Node.js e fornisce le linee guida per implementare la sincronizzazione e l'invio delle playlist locali e dei canti personalizzati ("canti miei") dalla PWA.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Dettagli dell'Endpoint `/miei`
|
||||
|
||||
- **Metodo HTTP**: `POST`
|
||||
- **URL di Sviluppo**: `http://localhost:3000/miei` (o l'IP/porta del tuo server in produzione)
|
||||
- **Content-Type**: `application/json`
|
||||
- **Limite Dimensione Body**: `10MB` (per supportare liste e testi completi di canti)
|
||||
|
||||
### 🔑 Identificazione Utente (`uid`)
|
||||
L'endpoint richiede l'identificativo unico dell'utente (`uid`). Può essere trasmesso in tre modi (in ordine di priorità):
|
||||
1. **Header HTTP**: `x-user-uid` (scelta consigliata) o `uid`
|
||||
2. **Query Parameter**: `?uid=<user_uid>`
|
||||
3. **Wrapper nel Body**: Se il corpo è un oggetto del tipo `{ "uid": "...", "canti": [...] }`
|
||||
|
||||
> [!WARNING]
|
||||
> L'UID dell'utente deve essere una stringa valida e sicura. Sono accettati solo caratteri alfanumerici, trattini e trattini bassi (`^[a-zA-Z0-9_\-]+$`). Qualsiasi tentativo di path traversal (es. contenente `.` o `/`) restituirà un errore `400 Bad Request`.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Formato dei Dati Richiesto (Payload)
|
||||
|
||||
L'endpoint si aspetta di ricevere un **array JSON** contenente la lista dei canti dell'utente (formato analogo a `canti.json`).
|
||||
|
||||
### Esempio di Payload (Array di Canti)
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id_canti": 9001,
|
||||
"titolo": "Mio Canto Personalizzato 1",
|
||||
"momenti": ["Ingresso"],
|
||||
"periodi": ["Lode"],
|
||||
"testo": "Testo del mio canto personalizzato...\nRit: Alleluia!"
|
||||
},
|
||||
{
|
||||
"id_canti": 9002,
|
||||
"titolo": "Mio Canto Personalizzato 2",
|
||||
"momenti": ["Comunione"],
|
||||
"periodi": [],
|
||||
"testo": "Testo del secondo canto..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Comportamento del Server
|
||||
|
||||
1. **Ricezione e Validazione**:
|
||||
- Estrae l'UID ed il payload dei canti.
|
||||
- Valida l'UID (sicurezza path traversal) e verifica che il payload sia un array JSON. In caso contrario, risponde con `400 Bad Request`.
|
||||
2. **Logica di Backup**:
|
||||
- Se nella cartella `data/` del server esiste già un file associato all'utente (`data/<uid>.json`), ne crea automaticamente una copia di backup rinominandola o copiandola in `data/<uid>.bak.json`.
|
||||
3. **Salvataggio**:
|
||||
- Scrive il nuovo JSON in `data/<uid>.json`.
|
||||
- Risponde con `200 OK` e un JSON di conferma:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "File salvato con successo.",
|
||||
"uid": "utente_test_123",
|
||||
"backupCreated": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Requisiti per l'Implementazione nella PWA (Client Locale Mac M1)
|
||||
|
||||
L'istanza di Antigravity che lavora sul codice della PWA locale dovrà implementare una funzione di sincronizzazione (es. `syncLocalDataToServer`) che esegua i seguenti passi:
|
||||
|
||||
1. **Recupero dei dati locali**:
|
||||
- Estrarre le playlist locali e i canti personalizzati creati dall'utente (solitamente salvati in `localStorage`, `IndexedDB`, o altro database locale).
|
||||
2. **Normalizzazione e Formattazione**:
|
||||
- Formattare e unire queste informazioni in un unico array JSON strutturato con i campi chiave per ciascun canto (`id_canti`, `titolo`, `momenti`, `periodi`, `testo`).
|
||||
3. **Invio al Server**:
|
||||
- Effettuare una chiamata `fetch` (POST) verso l'endpoint `/miei`.
|
||||
- Passare l'UID dell'utente autenticato (es. da Firebase Auth o altro sistema di sessione) tramite l'header `x-user-uid`.
|
||||
4. **Gestione del Feedback**:
|
||||
- Mostrare all'utente una notifica di successo o gestire eventuali errori di rete o validazione.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Piano di Implementazione: Importazione Playlist e Canti della Comunità
|
||||
|
||||
Questo piano descrive i dettagli tecnici per l'aggiunta di una funzione che consente di importare localmente tutte le playlist e i canti personalizzati di una comunità attiva.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> L'importazione comporterà il salvataggio dei canti personalizzati della comunità direttamente nei "Miei Canti" dell'utente (evitando duplicati) e la creazione di playlist locali. Le playlist locali create includeranno:
|
||||
> 1. Una playlist principale con il nome della comunità stessa contenente tutti i canti della comunità con le rispettive tonalità, velocità di scorrimento e testi personalizzati/aggiuntivi.
|
||||
> 2. Tutte le singole scalette/playlist della comunità importate come playlist locali indipendenti e modificabili.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### Servizi e Logica di Business
|
||||
|
||||
#### [MODIFY] [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
|
||||
Aggiungeremo un metodo helper o esporremo le funzioni di salvataggio di massa per le playlist importate, garantendo che le impostazioni dei brani (`songSettings` contenenti tonalità e velocità di scorrimento) siano preservate correttamente.
|
||||
|
||||
### Pagina Impostazioni (Settings Page)
|
||||
|
||||
#### [MODIFY] [settings.page.html](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.html)
|
||||
Nel gruppo "Comunità", quando una comunità è attiva, aggiungeremo un nuovo pulsante sotto i dettagli della comunità:
|
||||
- **Pulsante**: "Importa Playlist e Canti" con icona `cloud-download-outline`.
|
||||
|
||||
#### [MODIFY] [settings.page.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.ts)
|
||||
Implementeremo il metodo `importComunitaPlaylists()` che svolgerà i seguenti passaggi:
|
||||
1. **Verifica dello Stato**: Si assicura che una comunità sia attiva e che i dati siano caricati.
|
||||
2. **Importazione dei Canti Personalizzati**:
|
||||
- Scorrerà i canti di `comunitaService.comunitaCantiPersonali()`.
|
||||
- Per ciascun canto, verificherà se è già presente nei "Miei Canti" (tramite `myCantiService.myCanti()`).
|
||||
- Se assente, lo aggiungerà con prefisso ID `my_` ed effettuerà il salvataggio in locale su `MyCantiService`.
|
||||
3. **Creazione della Playlist della Comunità**:
|
||||
- Creerà una playlist avente come nome il nome della comunità stessa.
|
||||
- Popolerà i canti associando sia i canti standard sia i canti personali importati.
|
||||
- Assocerà le impostazioni di tonalità e velocità di scorrimento da `comunitaCantiSettings()`. Per la velocità, si utilizzerà la formula di conversione standard (es. `Math.round(speed / 100)`).
|
||||
4. **Importazione delle Scalette (Playlist) Aggiuntive**:
|
||||
- Per ciascuna scaletta in `comunitaService.comunitaScalette()`, creerà una playlist locale corrispondente.
|
||||
- Per ciascun canto della scaletta, cercherà se esiste un'impostazione in `comunitaCantiSettings()` per impostare tonalità e velocità specifiche all'interno della playlist.
|
||||
5. **Salvataggio e Feedback**:
|
||||
- Salverà tutte le nuove playlist tramite `playlistService.savePlaylist()`.
|
||||
- Mostrerà un alert di conferma iniziale e un toast di successo finale.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Manual Verification
|
||||
- Attivare una comunità di prova (es. con codice noto o inserendone uno valido).
|
||||
- Andare nelle impostazioni e cliccare sul nuovo pulsante "Importa Playlist e Canti".
|
||||
- Verificare la comparsa dell'alert di conferma.
|
||||
- Confermare l'importazione.
|
||||
- Verificare tramite Toast e navigando nella home che:
|
||||
- Sia presente una playlist locale con il nome della comunità contenente tutti i canti ordinati e configurati.
|
||||
- Siano presenti le playlist secondarie della comunità convertite in playlist locali.
|
||||
- Le tonalità modificate e le velocità di scorrimento siano state importate correttamente per ciascun canto all'interno delle playlist.
|
||||
- I canti personali/aggiuntivi siano stati importati nella sezione "Miei Canti" e siano visualizzabili.
|
||||
@@ -0,0 +1,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à. |
|
||||
@@ -0,0 +1,159 @@
|
||||
# Piano delle Azioni per la Generazione di una Batteria di Test (`.spec.ts`)
|
||||
|
||||
Questo documento descrive in dettaglio la strategia, l'analisi dello stato attuale e l'elenco delle azioni sequenziali necessarie per implementare una suite completa di test unitari e di integrazione per tutte le classi (servizi, pagine e componenti) dell'applicazione Angular/Ionic.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analisi dello Stato Attuale dei Test
|
||||
|
||||
L'applicazione utilizza **Angular v20**, **Ionic v8**, e il framework di test standard **Jasmine** abbinato al test runner **Karma**.
|
||||
|
||||
Un'analisi del codice ha rilevato la seguente situazione per ciascun componente chiave:
|
||||
|
||||
### 1. Servizi (`src/app/services`)
|
||||
I servizi gestiscono la logica di business fondamentale (audio, sincronizzazione, storage, stato).
|
||||
* ❌ `audio-engine.service.ts` — **Nessun test**
|
||||
* ❌ `canti-letture.service.ts` — **Nessun test**
|
||||
* ❌ `canti.service.ts` — **Nessun test**
|
||||
* ❌ `comunita.service.ts` — **Nessun test**
|
||||
* ❌ `connectivity.service.ts` — **Nessun test**
|
||||
* ❌ `lyrics-parser.service.ts` — **Nessun test**
|
||||
* ❌ `media-session.service.ts` — **Nessun test**
|
||||
* ⚠️ `my-canti.service.ts` — **Spec esistente ma corrotto/obsoleto** (`my-canti.spec.ts` fa riferimento a una classe `MyCanti` che non esiste, la classe reale è `MyCantiService`).
|
||||
* ❌ `playlist.service.ts` — **Nessun test**
|
||||
* ❌ `settings.service.ts` — **Nessun test**
|
||||
* ❌ `stats.service.ts` — **Nessun test**
|
||||
* ❌ `theme.service.ts` — **Nessun test**
|
||||
* ❌ `youtube-player.service.ts` — **Nessun test**
|
||||
|
||||
### 2. Pagine (`src/app/pages` & `src/app/home`)
|
||||
Le pagine gestiscono la visualizzazione e l'interazione con l'utente.
|
||||
* ⚠️ `home.page.ts` — Spec esistente (`home.page.spec.ts`) ma limitato al boilerplate base (verifica solo la creazione).
|
||||
* ⚠️ `display.page.ts` — Spec esistente (`display.page.spec.ts`) solo boilerplate base.
|
||||
* ⚠️ `player.page.ts` — Spec esistente (`player.page.spec.ts`) solo boilerplate base.
|
||||
* ❌ `playlist.page.ts` — **Nessun test**
|
||||
* ⚠️ `propose-canto.page.ts` — Spec esistente (`propose-canto.page.spec.ts`) solo boilerplate base.
|
||||
* ❌ `settings.page.ts` — **Nessun test**
|
||||
|
||||
### 3. Componenti (`src/app/components`)
|
||||
* ❌ `qr-scanner/qr-scanner.component.ts` — **Nessun test**
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Strategia di Mocking e Test in Angular/Ionic
|
||||
|
||||
Per testare efficacemente queste classi in isolamento, definiremo una strategia di mocking standard:
|
||||
|
||||
1. **Storage di Ionic (`@ionic/storage-angular`)**:
|
||||
Molti servizi (`MyCantiService`, `SettingsService`, `ComunitaService`) dipendono dallo Storage. Creeremo un mock per simulare i metodi `get`, `set` e `remove` usando una mappa in memoria.
|
||||
2. **Controller di Ionic (`ToastController`, `ModalController`, `AlertController`)**:
|
||||
Utilizzeremo `jasmine.createSpyObj` per simulare la creazione e la presentazione dei componenti UI di Ionic senza istanziarli realmente nel DOM durante i test dei servizi.
|
||||
3. **Ambiente HTML5 (Audio, Window)**:
|
||||
Servizi come `AudioEngineService` e `YoutubePlayerService` interagiscono con le API audio del browser e l'oggetto `window`. Useremo dei mock o spy su `Audio`, `window.location`, ecc.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Elenco Dettagliato delle Azioni da Compiere
|
||||
|
||||
Le azioni sono suddivise in 6 fasi logiche per garantire un approccio sistematico e sicuro.
|
||||
|
||||
### Fase 1: Verifica dell'Ambiente e Correzione dei Test Esistenti
|
||||
Prima di scrivere nuovi test, dobbiamo assicurarci che l'infrastruttura di test esistente sia stabile e funzionante.
|
||||
|
||||
- [ ] **Azione 1.1: Esecuzione iniziale dei test**
|
||||
* Eseguire `npm run test` (o `ng test --watch=false`) per verificare se la suite iniziale compila ed è verde.
|
||||
- [ ] **Azione 1.2: Correzione di `my-canti.spec.ts`**
|
||||
* Rinominare il file in `my-canti.service.spec.ts` per uniformità.
|
||||
* Correggere gli import: importare `MyCantiService` anziché `MyCanti`.
|
||||
* Configurare il `TestBed` fornendo il mock per `Storage`, `CantiService` e `ToastController`.
|
||||
* Verificare che il test passi con successo.
|
||||
- [ ] **Azione 1.3: Aggiornamento dei boilerplate delle pagine esistenti**
|
||||
* Risolvere eventuali errori nei test pregenerati (`home.page.spec.ts`, `display.page.spec.ts`, `player.page.spec.ts`, `propose-canto.page.spec.ts`) fornendo i moduli minimi (`IonicModule`, `RouterTestingModule`) e i mock dei servizi iniettati nei loro costruttori.
|
||||
|
||||
---
|
||||
|
||||
### Fase 2: Implementazione dei Test sui Servizi (Core Logico)
|
||||
I servizi sono la priorità in quanto non hanno dipendenze grafiche complesse e contengono la logica di business principale.
|
||||
|
||||
- [ ] **Azione 2.1: Test per `connectivity.service.ts` e `theme.service.ts`**
|
||||
* *Perché:* Sono i più semplici e privi di dipendenze esterne pesanti.
|
||||
* *Test:* Cambiamenti di stato della rete online/offline, applicazione corretta delle classi CSS per i temi (scuro/chiaro).
|
||||
- [ ] **Azione 2.2: Test per `lyrics-parser.service.ts`**
|
||||
* *Casi di test:* Parsing corretto dei testi dei canti con accordi tra parentesi (es. `[Do]`), gestione delle righe vuote, estrazione del testo pulito, formattazione.
|
||||
- [ ] **Azione 2.3: Test per `settings.service.ts`**
|
||||
* *Casi di test:* Caricamento delle impostazioni di default da storage, salvataggio di nuove impostazioni, gestione del cambio di dimensione del testo, export/import delle impostazioni.
|
||||
- [ ] **Azione 2.4: Test per `stats.service.ts`**
|
||||
* *Casi di test:* Incremento delle statistiche di visualizzazione di un canto, persistenza su storage, generazione dei report dei canti più cantati.
|
||||
- [ ] **Azione 2.5: Test per `canti.service.ts` e `canti-letture.service.ts`**
|
||||
* *Casi di test:* Lettura del database dei canti, filtri di ricerca per titolo/testo/momento liturgico, associazione tra letture del giorno e canti consigliati (sulla base delle strategie liturgiche descritte in `liturgia_strategy.md`).
|
||||
- [ ] **Azione 2.6: Test per `comunita.service.ts`**
|
||||
* *Casi di test:* Gestione del codice comunità, sincronizzazione dei canti della comunità tramite chiamate HTTP (mocking di `HttpClient`), salvataggio in storage locale.
|
||||
- [ ] **Azione 2.7: Test per `playlist.service.ts`**
|
||||
* *Casi di test:* Creazione, modifica e cancellazione di playlist, riordino dei canti all'interno di una playlist, salvataggio e ripristino da storage.
|
||||
- [ ] **Azione 2.8: Test per `media-session.service.ts`**
|
||||
* *Casi di test:* Integrazione con l'API `navigator.mediaSession` del browser, aggiornamento dei metadati audio (titolo, autore, copertina), gestione degli eventi di riproduzione/pausa del sistema operativo.
|
||||
- [ ] **Azione 2.9: Test per `audio-engine.service.ts` e `youtube-player.service.ts`**
|
||||
* *Casi di test:* Avvio, pausa, stop e seek delle tracce audio locali e dei video YouTube, gestione degli eventi di fine riproduzione, aggiornamento dello stato tramite Signals/RxJS.
|
||||
|
||||
---
|
||||
|
||||
### Fase 3: Implementazione dei Test sui Componenti Condivisi
|
||||
- [ ] **Azione 3.1: Test per `qr-scanner.component.ts`**
|
||||
* *Casi di test:* Inizializzazione della fotocamera, gestione dei permessi negati, decodifica del codice QR (simulando l'evento del decoder), emissione del codice scansionato tramite `@Output()`, interruzione dello streaming video alla distruzione del componente.
|
||||
|
||||
---
|
||||
|
||||
### Fase 4: Implementazione e Arricchimento dei Test sulle Pagine (UI & Controller)
|
||||
Qui testeremo l'integrazione tra i servizi (mockati) e l'interfaccia utente di Ionic.
|
||||
|
||||
- [ ] **Azione 4.1: Test approfonditi per `home.page.ts`**
|
||||
* *Casi di test:* Visualizzazione dell'elenco dei canti, funzionamento della barra di ricerca (filtro istantaneo), apertura dei dettagli del canto, gestione della navigazione verso le altre pagine.
|
||||
- [ ] **Azione 4.2: Test approfonditi per `player.page.ts`**
|
||||
* *Casi di test:* Interazione con i pulsanti di play/pause, scorrimento del testo a tempo, trasposizione degli accordi (es. +1 semitono, -1 semitono) con aggiornamento dinamico del testo a schermo.
|
||||
- [ ] **Azione 4.3: Test approfonditi per `display.page.ts`**
|
||||
* *Casi di test:* Rendering corretto del testo del canto, applicazione del tema visivo e della dimensione del font dalle impostazioni, gestione del blocco dello spegnimento dello schermo (se implementato).
|
||||
- [ ] **Azione 4.4: Creazione e test per `playlist.page.ts`**
|
||||
* *Casi di test:* Visualizzazione dell'elenco delle playlist dell'utente, creazione di una nuova playlist tramite popup, aggiunta di canti, eliminazione di canti con swipe.
|
||||
- [ ] **Azione 4.5: Test approfonditi per `propose-canto.page.ts`**
|
||||
* *Casi di test:* Validazione del form di proposta (titolo obbligatorio, testo minimo), generazione della mailto URL corretta con il body in formato JSON, comportamento del pulsante di invio.
|
||||
- [ ] **Azione 4.6: Creazione e test per `settings.page.ts`**
|
||||
* *Casi di test:* Toggle del tema scuro (con verifica dell'applicazione al DOM), selezione della dimensione del font, reset dei dati locali con richiesta di conferma tramite `AlertController`.
|
||||
|
||||
---
|
||||
|
||||
### Fase 5: Raccolta delle Metriche di Coverage e Rifinitura
|
||||
- [ ] **Azione 5.1: Configurazione del report di coverage**
|
||||
* Assicurarsi che `karma.conf.js` sia configurato per esportare i dati in formato `lcov` o `html` (tramite `karma-coverage`).
|
||||
- [ ] **Azione 5.2: Generazione del report**
|
||||
* Eseguire `ng test --code-coverage --watch=false`.
|
||||
* Esaminare la cartella `coverage/` generata per individuare eventuali rami logici (branches) o righe non coperte nei servizi core.
|
||||
- [ ] **Azione 5.3: Incremento mirato del coverage**
|
||||
* Aggiungere test specifici per coprire i casi limite (*edge cases*) e la gestione degli errori (es. fallimento delle chiamate HTTP, storage corrotto).
|
||||
|
||||
---
|
||||
|
||||
### Fase 6: Automazione in CI/CD (Opzionale ma Consigliato)
|
||||
- [ ] **Azione 6.1: Configurazione test headless**
|
||||
* Configurare Karma per eseguire i test in modalità headless usando `ChromeHeadless` su sistemi di Continuous Integration (es. GitHub Actions).
|
||||
* Aggiungere uno script npm `test:ci`: `"test:ci": "ng test --watch=false --browsers=ChromeHeadless"`.
|
||||
|
||||
---
|
||||
|
||||
## 📈 Tabella di Marcia Consigliata
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Roadmap per la Copertura dei Test
|
||||
dateFormat YYYY-MM-DD
|
||||
section Fase 1
|
||||
Verifica & Fix Esistenti :active, 2026-05-21, 2d
|
||||
section Fase 2
|
||||
Test Servizi Core : 2026-05-23, 5d
|
||||
section Fase 3 & 4
|
||||
Test Componenti & Pagine : 2026-05-28, 5d
|
||||
section Fase 5 & 6
|
||||
Coverage & CI/CD : 2026-06-02, 2d
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Consiglio per l'efficienza**: Si raccomanda di iniziare ad implementare i test partendo dai servizi a più basso livello (come `ConnectivityService`, `ThemeService`, `LyricsParserService`) perché sono privi di dipendenze e consentono di stabilire rapidamente dei pattern di test solidi prima di affrontare servizi più complessi o interfacce grafiche.
|
||||
@@ -8,3 +8,12 @@
|
||||
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
|
||||
RewriteRule ^ index.html [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker
|
||||
<FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest|version\.json">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires 0
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
print("Fetching...")
|
||||
with urllib.request.urlopen(req) as res:
|
||||
data = json.loads(res.read().decode("utf-8"))
|
||||
|
||||
settings = data.get("canti_settings", {}).get("data", [])
|
||||
print(f"Total settings: {len(settings)}")
|
||||
for s in settings[:10]:
|
||||
print(s)
|
||||
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from ftplib import FTP, error_perm
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
thread_local = threading.local()
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
if os.path.exists('.env'):
|
||||
with open('.env', 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
if '=' in line:
|
||||
key, val = line.split('=', 1)
|
||||
env[key.strip()] = val.strip()
|
||||
return env
|
||||
|
||||
def get_ftp_connection(ftp_host, ftp_user, ftp_pass):
|
||||
if not hasattr(thread_local, "ftp") or thread_local.ftp is None:
|
||||
ftp = FTP(ftp_host, timeout=30)
|
||||
ftp.login(ftp_user, ftp_pass)
|
||||
ftp.passive = True
|
||||
thread_local.ftp = ftp
|
||||
return thread_local.ftp
|
||||
|
||||
def close_thread_connection():
|
||||
if hasattr(thread_local, "ftp") and thread_local.ftp is not None:
|
||||
try:
|
||||
thread_local.ftp.quit()
|
||||
except:
|
||||
try:
|
||||
thread_local.ftp.close()
|
||||
except:
|
||||
pass
|
||||
thread_local.ftp = None
|
||||
|
||||
def ensure_remote_dir(ftp, path):
|
||||
parts = path.replace('\\', '/').strip('/').split('/')
|
||||
current = ""
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
current += "/" + part
|
||||
try:
|
||||
ftp.mkd(current)
|
||||
print(f"\nCreated remote dir: {current}")
|
||||
except error_perm:
|
||||
# Directory already exists or permission error (which is normal if it exists)
|
||||
pass
|
||||
|
||||
def upload_file_task(local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock):
|
||||
ftp_path = rel_path.replace('\\', '/')
|
||||
remote_dir = os.path.dirname(ftp_path)
|
||||
|
||||
# Ensure remote directory exists
|
||||
if remote_dir:
|
||||
with created_dirs_lock:
|
||||
if remote_dir not in created_dirs:
|
||||
try:
|
||||
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
created_dirs.add(remote_dir)
|
||||
except Exception as e:
|
||||
thread_local.ftp = None
|
||||
try:
|
||||
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
created_dirs.add(remote_dir)
|
||||
except Exception as err2:
|
||||
return False, ftp_path, f"Failed directory creation: {err2}"
|
||||
|
||||
# Upload the file
|
||||
for attempt in range(3):
|
||||
try:
|
||||
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||
with open(local_path, 'rb') as f:
|
||||
ftp.storbinary(f"STOR {ftp_path}", f)
|
||||
return True, ftp_path, None
|
||||
except Exception as e:
|
||||
thread_local.ftp = None # Force reconnect on next attempt
|
||||
if attempt == 2:
|
||||
return False, ftp_path, str(e)
|
||||
return False, ftp_path, "Unknown error"
|
||||
|
||||
def main():
|
||||
env = load_env()
|
||||
ftp_host = "ftp.canticristiani.it"
|
||||
ftp_user = "canticristiani.it"
|
||||
ftp_pass = env.get("FTP_PASSWORD")
|
||||
|
||||
if not ftp_pass:
|
||||
print("❌ Error: FTP_PASSWORD not found in .env")
|
||||
sys.exit(1)
|
||||
|
||||
local_dir = "www"
|
||||
if not os.path.isdir(local_dir):
|
||||
print(f"❌ Error: Local dir '{local_dir}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
files_to_upload = []
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
for file in files:
|
||||
local_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(local_path, local_dir)
|
||||
files_to_upload.append((local_path, rel_path))
|
||||
|
||||
total = len(files_to_upload)
|
||||
uploaded = 0
|
||||
errors = []
|
||||
|
||||
created_dirs = set()
|
||||
created_dirs_lock = threading.Lock()
|
||||
progress_lock = threading.Lock()
|
||||
|
||||
# Thread limit: default to 100 as requested
|
||||
num_threads = int(os.environ.get("FTP_THREADS", env.get("FTP_THREADS", "100")))
|
||||
print(f"🚀 Starting parallel upload of {total} files using {num_threads} threads...")
|
||||
|
||||
def run_task(item):
|
||||
nonlocal uploaded
|
||||
local_path, rel_path = item
|
||||
success, ftp_path, err_msg = upload_file_task(
|
||||
local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock
|
||||
)
|
||||
with progress_lock:
|
||||
uploaded += 1
|
||||
print(f"\rUploading [{uploaded}/{total}] {ftp_path}...", end="", flush=True)
|
||||
if not success:
|
||||
errors.append((ftp_path, err_msg))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = [executor.submit(run_task, item) for item in files_to_upload]
|
||||
for future in as_completed(futures):
|
||||
pass
|
||||
|
||||
# Cleanup connections
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as cleanup_executor:
|
||||
cleanups = [cleanup_executor.submit(close_thread_connection) for _ in range(num_threads)]
|
||||
for future in as_completed(cleanups):
|
||||
pass
|
||||
|
||||
if errors:
|
||||
print(f"\n⚠️ Deploy completed with {len(errors)} errors:")
|
||||
for path, err in errors:
|
||||
print(f" {path}: {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n\n✅ Deploy completed successfully with 100% files uploaded without errors!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
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));
|
||||
@@ -0,0 +1,34 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
print("Fetching...")
|
||||
with urllib.request.urlopen(req) as res:
|
||||
data = json.loads(res.read().decode("utf-8"))
|
||||
|
||||
canti_pers = data.get("canti_personali", {}).get("data", [])
|
||||
states = {}
|
||||
for cp in canti_pers:
|
||||
state = cp.get("stato")
|
||||
states[state] = states.get(state, 0) + 1
|
||||
|
||||
print("States found in canti_personali:")
|
||||
for state, count in states.items():
|
||||
print(f"- State {state}: {count} songs")
|
||||
|
||||
# Let's also check if there are any duplicate IDs between canti and canti_personali
|
||||
canti_ids = {c["id_canti"] for c in data.get("canti", {}).get("data", [])}
|
||||
canti_pers_ids = {cp["id_canti"] for cp in canti_pers}
|
||||
intersection = canti_ids.intersection(canti_pers_ids)
|
||||
print(f"Number of duplicate song IDs between global catalog and canti_personali: {len(intersection)}")
|
||||
if len(intersection) > 0:
|
||||
print("First 5 duplicate IDs:")
|
||||
print(list(intersection)[:5])
|
||||
# Find one example of duplicate
|
||||
dup_id = list(intersection)[0]
|
||||
global_dup = next(c for c in data["canti"]["data"] if c["id_canti"] == dup_id)
|
||||
pers_dup = next(cp for cp in canti_pers if cp["id_canti"] == dup_id)
|
||||
print(f"\nExample Duplicate (ID {dup_id}):")
|
||||
print(f"Global: Title='{global_dup.get('titolo')}', Stato={global_dup.get('stato')}")
|
||||
print(f"Personal: Title='{pers_dup.get('titolo')}', Stato={pers_dup.get('stato')}")
|
||||
@@ -0,0 +1,11 @@
|
||||
import urllib.request
|
||||
|
||||
url = "https://app.canticristiani.it/js/db.js"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req) as res:
|
||||
content = res.read().decode("utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
print("================== js/db.js lines 1260 to 1310 ==================")
|
||||
for i in range(1259, min(1310, len(lines))):
|
||||
print(f"{i+1}: {lines[i]}")
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.chord-segment {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.2em;
|
||||
border: 1px solid blue;
|
||||
}
|
||||
.chord {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: red;
|
||||
height: 1.2em;
|
||||
margin-bottom: -0.2em;
|
||||
border: 1px solid red;
|
||||
}
|
||||
.seg-text {
|
||||
white-space: pre;
|
||||
border: 1px solid green;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
ti amerò come
|
||||
<span class="chord-segment"><span class="chord">LA</span><span class="seg-text"> </span></span>
|
||||
<span class="chord-segment"><span class="chord">MI</span><span class="seg-text"></span></span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
const textWords = [
|
||||
{ text: 'come', bbox: { x0: 40, x1: 60 } },
|
||||
{ text: 'sei', bbox: { x0: 70, x1: 90 } }
|
||||
];
|
||||
|
||||
const chordWords = [
|
||||
{ text: 'la', bbox: { x0: 65, x1: 75 } },
|
||||
{ text: 'mi', bbox: { x0: 95, x1: 105 } }
|
||||
];
|
||||
|
||||
function mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
|
||||
let result = '';
|
||||
const chordAssignments = new Map<any, any[]>();
|
||||
|
||||
chordWords.forEach(chord => {
|
||||
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
||||
let closestWord: any = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
textWords.forEach(textWord => {
|
||||
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
|
||||
const dist = Math.abs(chordX - wordXCenter);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestWord = textWord;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestWord) {
|
||||
if (!chordAssignments.has(closestWord)) {
|
||||
chordAssignments.set(closestWord, []);
|
||||
}
|
||||
chordAssignments.get(closestWord)!.push(chord);
|
||||
}
|
||||
});
|
||||
|
||||
textWords.forEach((textWord, index) => {
|
||||
const assignedChords = chordAssignments.get(textWord) || [];
|
||||
|
||||
// Split chords into before and after the word
|
||||
const chordsBefore = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 <= textWord.bbox.x1);
|
||||
const chordsAfter = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 > textWord.bbox.x1);
|
||||
|
||||
chordsBefore.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||
chordsAfter.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||
|
||||
chordsBefore.forEach(chord => {
|
||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||
result += `[${cleanChord}]`;
|
||||
});
|
||||
|
||||
result += textWord.text;
|
||||
|
||||
chordsAfter.forEach(chord => {
|
||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||
result += `[${cleanChord}]`;
|
||||
});
|
||||
|
||||
if (index < textWords.length - 1) {
|
||||
result += ' ';
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
console.log(mergeChordsAndLyrics(chordWords, textWords));
|
||||
@@ -0,0 +1,184 @@
|
||||
# Strategia SEO e Indicizzazione per la PWA dei Canti
|
||||
|
||||
Questo documento definisce la strategia tecnica per consentire l'indicizzazione dei testi dei canti da parte dei motori di ricerca (in particolare Googlebot) e la corretta generazione delle anteprime (social cards) su piattaforme di messaggistica e social media (WhatsApp, Telegram, Facebook, ecc.), mantenendo intatta l'architettura PWA (Progressive Web App) offline-ready.
|
||||
|
||||
---
|
||||
|
||||
## 1. Il Contesto Tecnologico
|
||||
L'applicazione è sviluppata con:
|
||||
- **Angular 20** (Framework Core)
|
||||
- **Ionic 8** (UI & Routing integration)
|
||||
- **Angular Service Worker (`@angular/service-worker`)** per le funzionalità offline della PWA.
|
||||
|
||||
---
|
||||
|
||||
## 2. La Strategia Selezionata: Prerendering / Static Site Generation (SSG)
|
||||
|
||||
Dato che i testi dei canti sono **dati statici** (non cambiano in base all'utente connesso e variano molto raramente), la soluzione ottimale è la **Static Site Generation (SSG)**, nota anche come **Prerendering**.
|
||||
|
||||
### Come Funziona la Sinergia SSG + PWA
|
||||
1. **Fase di Build:** Durante la compilazione dell'app (`ng build`), Angular genera un file `index.html` statico e pre-renderizzato per ogni singolo canto (es. `/canti/tu-sei-sorgente/index.html`).
|
||||
2. **Scansione dello Spider (SEO):** Quando Googlebot o i crawler dei social richiedono l'URL di un canto, il server o la CDN distribuiscono immediatamente il file HTML statico già popolato con il testo del canto e con i meta tag corretti.
|
||||
3. **Idratazione e PWA (Client):** Quando un utente apre la pagina sul browser, Angular scarica i bundle JavaScript ed esegue l'**hydration** in background. L'applicazione "prende vita" come Single Page Application (SPA), attiva il Service Worker e abilita la navigazione offline e l'installabilità come PWA.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fasi e Dettagli di Implementazione
|
||||
|
||||
### Fase 1: Struttura degli URL e Routing Semantico
|
||||
Per facilitare la SEO, gli URL devono essere parlanti e privi di simboli di hash (`#`). Attualmente, l'applicazione utilizza già il routing basato su percorsi standard (PathLocationStrategy) in [app-routing.module.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/app-routing.module.ts).
|
||||
|
||||
È necessario definire una rotta dedicata per i singoli canti che accetti un parametro semantico (detto *slug* o *alias*), ad esempio:
|
||||
|
||||
```typescript
|
||||
// src/app/app-routing.module.ts
|
||||
const routes: Routes = [
|
||||
// ... altre rotte
|
||||
{
|
||||
path: 'canti/:slug',
|
||||
loadChildren: () => import('./pages/canto-detail/canto-detail.module').then(m => m.CantoDetailPageModule)
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
I collegamenti all'interno dell'applicazione per navigare verso i canti devono utilizzare il tag semantico `<a>` con la direttiva `routerLink`, per consentire a Googlebot di scoprire autonomamente tutte le pagine:
|
||||
|
||||
```html
|
||||
<!-- EVITARE: pulsanti generici con eventi click gestiti in JS -->
|
||||
<ion-item (click)="navigaAlCanto(canto.slug)">...</ion-item>
|
||||
|
||||
<!-- CONSIGLIATO: vero tag link HTML -->
|
||||
<a [routerLink]="['/canti', canto.slug]" class="canto-link">
|
||||
<ion-label>{{ canto.titolo }}</ion-label>
|
||||
</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fase 2: Gestione dei Meta Tag Dinamici
|
||||
Ogni canto deve avere un titolo e una descrizione univoci e ottimizzati per la SEO. In Angular si utilizzano i servizi `Title` e `Meta` di `@angular/platform-browser` per aggiornare i metadati all'inizializzazione del componente:
|
||||
|
||||
```typescript
|
||||
// src/app/pages/canto-detail/canto-detail.page.ts
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Title, Meta } from '@angular/platform-browser';
|
||||
import { CantiService } from '../../services/canti.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-canto-detail',
|
||||
templateUrl: './canto-detail.page.html',
|
||||
styleUrls: ['./canto-detail.page.scss'],
|
||||
})
|
||||
export class CantoDetailPage implements OnInit {
|
||||
canto: any;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private cantiService: CantiService,
|
||||
private titleService: Title,
|
||||
private metaService: Meta
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const slug = this.route.snapshot.paramMap.get('slug');
|
||||
if (slug) {
|
||||
this.canto = this.cantiService.getCantoBySlug(slug);
|
||||
this.updateSEOMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
updateSEOMetadata() {
|
||||
const titoloCompleto = `${this.canto.titolo} - Canti Cristiani`;
|
||||
const descrizione = `Testo e accordi del canto "${this.canto.titolo}". ${this.canto.testo.substring(0, 150)}...`;
|
||||
|
||||
// Imposta il titolo della pagina
|
||||
this.titleService.setTitle(titoloCompleto);
|
||||
|
||||
// Imposta i meta tag standard per la SEO
|
||||
this.metaService.updateTag({ name: 'description', content: descrizione });
|
||||
|
||||
// Imposta i tag OpenGraph per la condivisione sui social (Facebook, WhatsApp, Telegram)
|
||||
this.metaService.updateTag({ property: 'og:title', content: titoloCompleto });
|
||||
this.metaService.updateTag({ property: 'og:description', content: descrizione });
|
||||
this.metaService.updateTag({ property: 'og:type', content: 'article' });
|
||||
this.metaService.updateTag({ property: 'og:url', content: `https://canti.cristiani.it/canti/${this.canto.slug}` });
|
||||
|
||||
// Se c'è un'immagine associata o una copertina di default
|
||||
this.metaService.updateTag({ property: 'og:image', content: 'https://canti.cristiani.it/assets/og-cover.png' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fase 3: Configurazione del Prerendering (SSG) in Angular 20
|
||||
In Angular 20, l'abilitazione del server-side rendering e del prerendering statico durante la compilazione avviene aggiungendo il pacchetto SSR ufficiale:
|
||||
|
||||
```bash
|
||||
ng add @angular/ssr
|
||||
```
|
||||
|
||||
Questo comando configura automaticamente l'applicazione modificando `angular.json` e creando i file necessari per la compilazione lato server.
|
||||
|
||||
#### Configurazione delle rotte da pre-renderizzare
|
||||
Poiché l'elenco dei canti è dinamico (es. risiede in file JSON o database), dobbiamo indicare ad Angular quali rotte generare staticamente durante il comando `ng build`.
|
||||
|
||||
Si crea un file di configurazione per definire le rotte o si utilizza uno script per estrarle dinamicamente:
|
||||
|
||||
1. **Creare un file delle rotte statiche** `prerender-routes.txt`:
|
||||
```txt
|
||||
/home
|
||||
/settings
|
||||
/canti/tu-sei-sorgente
|
||||
/canti/re-dei-re
|
||||
/canti/lodi-al-altissimo
|
||||
```
|
||||
2. **Automatizzare la generazione di questo file** inserendo uno script (es. `generate-routes.js`) da eseguire prima della build che legge l'elenco dei canti dal file JSON locale e scrive l'elenco dei percorsi in `prerender-routes.txt`.
|
||||
3. **Configurare `angular.json`** per consumare questo file:
|
||||
```json
|
||||
"prerender": {
|
||||
"discoverRoutes": false,
|
||||
"routesFile": "prerender-routes.txt"
|
||||
}
|
||||
```
|
||||
|
||||
Al termine della build (`npm run build`), nella cartella di distribuzione (es. `dist/canticristiani/browser`) verranno generate cartelle fisiche con i file `index.html` pronti all'uso per ciascuna rotta definita.
|
||||
|
||||
---
|
||||
|
||||
### Fase 4: Sitemap.xml e Robots.txt
|
||||
Per garantire che Googlebot trovi tempestivamente tutti i canti, è fondamentale generare un file `sitemap.xml` da posizionare nella radice del server web.
|
||||
|
||||
#### Esempio di `sitemap.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://canti.cristiani.it/home</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<!-- Generato dinamicamente per ogni canto -->
|
||||
<url>
|
||||
<loc>https://canti.cristiani.it/canti/tu-sei-sorgente</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
```
|
||||
|
||||
#### Esempio di `robots.txt`:
|
||||
```txt
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://canti.cristiani.it/sitemap.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Vantaggi e Risultati Attesi
|
||||
- **Indicizzazione immediata:** Googlebot indicizzerà i testi dei canti all'istante, consentendo agli utenti di trovare la PWA cercando frammenti di testo o il titolo del canto direttamente su Google.
|
||||
- **Anteprime nei Social Perfette:** La condivisione dei link sui canali di comunicazione mostrerà anteprime ricche (titolo corretto, frammento del testo del canto e logo dell'app).
|
||||
- **Integrità PWA:** L'utente beneficerà di un caricamento iniziale ultra-veloce (grazie all'HTML pre-renderizzato) seguito dall'installazione offline e dall'esperienza fluida tipica dell'applicazione mobile.
|
||||
@@ -1,4 +1,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>
|
||||
|
||||
+726
-39
@@ -1,7 +1,87 @@
|
||||
import { Component, inject, ApplicationRef } from '@angular/core';
|
||||
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 { concat, interval, fromEvent } from 'rxjs';
|
||||
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',
|
||||
@@ -9,61 +89,668 @@ import { concat, interval, fromEvent } from 'rxjs';
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent {
|
||||
export class AppComponent implements OnInit {
|
||||
private themeService = inject(ThemeService); // Ensures theme is initialized at boot
|
||||
public cantiService = inject(CantiService);
|
||||
public settingsService = inject(SettingsService);
|
||||
public version = VERSION;
|
||||
private 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 appRef = inject(ApplicationRef);
|
||||
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() {
|
||||
this.setupUpdates();
|
||||
// Gli aggiornamenti automatici e periodici sono stati rimossi.
|
||||
// L'aggiornamento viene gestito esclusivamente in modo manuale
|
||||
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
private setupUpdates() {
|
||||
if (this.swUpdate.isEnabled) {
|
||||
// Wait for the application to stabilize before running update checks or starting intervals
|
||||
this.appRef.isStable.pipe(
|
||||
filter(stable => stable),
|
||||
first()
|
||||
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(() => {
|
||||
console.log('[PWA-Update] App is stable. Initializing update checks...');
|
||||
|
||||
// 1. Check for updates immediately
|
||||
this.swUpdate.checkForUpdate().catch(err => {
|
||||
console.warn('[PWA-Update] Failed immediate startup update check:', err);
|
||||
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
|
||||
});
|
||||
|
||||
// 2. Periodic check in background every 30 seconds
|
||||
const every30Seconds$ = interval(30 * 1000);
|
||||
every30Seconds$.subscribe(async () => {
|
||||
console.log('[PWA-Update] Periodic check for updates (every 30s)...');
|
||||
// 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 {
|
||||
await this.swUpdate.checkForUpdate();
|
||||
} catch (err) {
|
||||
console.warn('[PWA-Update] Failed periodic update check:', err);
|
||||
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;
|
||||
});
|
||||
|
||||
// 3. Check for updates when the app is resumed/focused
|
||||
fromEvent(document, 'visibilitychange')
|
||||
.pipe(filter(() => document.visibilityState === 'visible'))
|
||||
.subscribe(async () => {
|
||||
console.log('[PWA-Update] App resumed, checking for PWA updates...');
|
||||
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 {
|
||||
await this.swUpdate.checkForUpdate();
|
||||
} catch (err) {
|
||||
console.warn('[PWA-Update] Failed visible resume update check:', err);
|
||||
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;
|
||||
});
|
||||
|
||||
// 4. Activate update and reload when a new version is ready
|
||||
this.swUpdate.versionUpdates
|
||||
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
||||
.subscribe(() => {
|
||||
console.log('[PWA-Update] New version ready! Activating and reloading...');
|
||||
this.swUpdate.activateUpdate().then(() => {
|
||||
window.location.reload();
|
||||
console.log('[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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NgModule, isDevMode } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { RouteReuseStrategy } from '@angular/router';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { IonicStorageModule } from '@ionic/storage-angular';
|
||||
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||
@@ -9,6 +9,7 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
@@ -24,7 +25,10 @@ import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
registrationStrategy: 'registerImmediately'
|
||||
})
|
||||
],
|
||||
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true }
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<ion-header class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient">
|
||||
<ion-title class="outfit-font">QR Code di Ripristino</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="bg-gradient ion-padding">
|
||||
<div class="qr-container">
|
||||
<p class="description outfit-font">
|
||||
Salva questo QR Code (fai uno screenshot o scaricalo) per ripristinare il tuo account e le tue comunità se cambi dispositivo o reinstalli l'applicazione.
|
||||
</p>
|
||||
|
||||
<div class="qr-card glass">
|
||||
<div class="qr-wrapper" *ngIf="qrCodeUrl">
|
||||
<img [src]="qrCodeUrl" alt="QR Code di Ripristino" class="qr-image" />
|
||||
</div>
|
||||
|
||||
<div class="uuid-box">
|
||||
<span class="uuid-label outfit-font">Codice Identificativo:</span>
|
||||
<span class="uuid-text select-all outfit-font">{{ userUuid }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-wrapper">
|
||||
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
|
||||
<ion-icon name="copy-outline" slot="start"></ion-icon>
|
||||
Copia Codice Testuale
|
||||
</ion-button>
|
||||
|
||||
<a *ngIf="qrCodeUrl" [href]="qrCodeUrl" download="ripristino-identita-canti.png" class="download-btn outfit-font">
|
||||
<ion-icon name="download-outline"></ion-icon>
|
||||
Scarica Immagine QR
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,121 @@
|
||||
.qr-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ion-text-color);
|
||||
opacity: 0.9;
|
||||
margin-bottom: 24px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.qr-card {
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.uuid-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.uuid-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ion-color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uuid-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-text-color);
|
||||
word-break: break-all;
|
||||
opacity: 0.85;
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
background: rgba(var(--ion-text-color-rgb, 255, 255, 255), 0.04);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.actions-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin: 0;
|
||||
--border-radius: 14px;
|
||||
--box-shadow: 0 4px 16px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
font-weight: 600;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--ion-text-color);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Component, OnInit, inject, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity-qr-modal',
|
||||
templateUrl: './identity-qr-modal.component.html',
|
||||
styleUrls: ['./identity-qr-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule]
|
||||
})
|
||||
export class IdentityQrModalComponent implements OnInit {
|
||||
private modalCtrl = inject(ModalController);
|
||||
private toastCtrl = inject(ToastController);
|
||||
|
||||
@Input() userUuid!: string;
|
||||
public qrCodeUrl: string = '';
|
||||
|
||||
ngOnInit() {
|
||||
this.generateQr();
|
||||
}
|
||||
|
||||
async generateQr() {
|
||||
try {
|
||||
this.qrCodeUrl = await QRCode.toDataURL(this.userUuid, {
|
||||
errorCorrectionLevel: 'H',
|
||||
margin: 2,
|
||||
width: 400,
|
||||
color: {
|
||||
dark: '#1e293b', // Slate 800 for premium dark aesthetic contrast
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to generate QR Code:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.userUuid);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Codice copiato negli appunti!',
|
||||
duration: 2000,
|
||||
color: 'success',
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text:', err);
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,34 @@
|
||||
<div class="scanner-container">
|
||||
<zxing-scanner
|
||||
[formats]="allowedFormats"
|
||||
(scanSuccess)="onCodeResult($event)">
|
||||
[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>
|
||||
|
||||
@@ -55,6 +55,91 @@
|
||||
padding: 0 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.camera-toggle-container {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.camera-toggle-btn {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 30px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(0.96);
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.37);
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ipad-warning-container {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
|
||||
|
||||
ion-icon {
|
||||
font-size: 24px;
|
||||
color: var(--ion-color-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
|
||||
strong {
|
||||
color: var(--ion-color-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
|
||||
@@ -16,12 +16,60 @@ 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();
|
||||
}
|
||||
}
|
||||
+354
-140
@@ -5,18 +5,26 @@
|
||||
<img src="assets/icon/favicon.png" class="header-logo">
|
||||
<div class="header-text-group">
|
||||
<span class="app-name">{{ appName }}</span>
|
||||
<span class="version-badge">v{{ version }}</span>
|
||||
<span class="version-badge" 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>
|
||||
<ion-button routerLink="/propose-canto" class="add-btn" *ngIf="!playlistService.selectionMode() && settingsService.showEditor()">
|
||||
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ng-template>
|
||||
<ion-button routerLink="/settings" class="settings-btn">
|
||||
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -30,20 +38,134 @@
|
||||
placeholder="Cerca un canto..."
|
||||
[value]="searchQuery()"
|
||||
(ionInput)="onSearch($event)"
|
||||
[disabled]="isAdvancedSearchOpen()"
|
||||
class="custom-searchbar">
|
||||
</ion-searchbar>
|
||||
<ion-button fill="clear" (click)="toggleVoiceSearch()" class="voice-search-btn">
|
||||
<ion-icon slot="icon-only" [name]="audioEngine.isSearching() ? 'mic' : 'mic-outline'" [color]="audioEngine.isSearching() ? 'danger' : 'secondary'"></ion-icon>
|
||||
<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>
|
||||
|
||||
<div class="filter-actions-row">
|
||||
<!-- 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>
|
||||
<div class="filter-buttons">
|
||||
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
||||
<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"
|
||||
@@ -54,53 +176,110 @@
|
||||
<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
|
||||
*ngIf="comunitaService.comunitaCode()"
|
||||
(click)="editComunitaCode($event)"
|
||||
style="position: absolute; top: -6px; right: -6px; z-index: 99999; background: var(--ion-color-secondary); color: white; border-radius: 50%; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; border: 1.5px solid #1a1a1a; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"
|
||||
class="floating-edit-badge">
|
||||
<ion-icon name="create-outline" style="font-size: 0.85rem; pointer-events: none;"></ion-icon>
|
||||
*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>
|
||||
|
||||
<ion-button
|
||||
*ngIf="settingsService.showEditor()"
|
||||
[fill]="showOnlyMine() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleOnlyMine()"
|
||||
color="secondary"
|
||||
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>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'liturgico' || selectedLiturgico() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('liturgico')"
|
||||
class="filter-chip">
|
||||
{{ selectedLiturgico() !== null ? getSelectedLiturgicoLabel() : 'Liturgia' }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'tematico' || selectedTematico() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('tematico')"
|
||||
class="filter-chip">
|
||||
{{ selectedTematico() !== null ? getSelectedTematicoLabel() : 'Periodo' }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
<!-- 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"
|
||||
@@ -112,19 +291,6 @@
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()"
|
||||
class="filter-chip">
|
||||
{{ playlistService.activeListName() !== null ? playlistService.activeListName() : 'Playlist' }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length > 0"></ion-icon>
|
||||
<ion-icon slot="end" name="add-circle-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length === 0"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
@@ -135,51 +301,78 @@
|
||||
<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>
|
||||
|
||||
<!-- Category Scroll (Dropdown style) -->
|
||||
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="activeFilterType() || playlistService.selectionMode() || (playlistService.activeListName() && !playlistService.selectionMode())">
|
||||
<div class="momento-scroll">
|
||||
<!-- Selection Mode Actions -->
|
||||
<div class="selection-pill glass" *ngIf="playlistService.selectionMode()">
|
||||
<span class="selection-count">{{ playlistService.selectedIds().size }}</span>
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="finishSelection()" [disabled]="playlistService.selectedIds().size === 0" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="danger" (click)="cancelSelection()" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<!-- 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() && !playlistService.selectionMode()">
|
||||
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist()">
|
||||
<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.activePlaylistId() && !isComunitaPlaylist()">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
<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>
|
||||
|
||||
<!-- Filter chips -->
|
||||
<div
|
||||
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
|
||||
class="momento-chip glass"
|
||||
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
|
||||
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
|
||||
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
|
||||
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
||||
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
|
||||
<!-- 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>
|
||||
@@ -192,33 +385,12 @@
|
||||
(click)="onInteraction()">
|
||||
|
||||
<div class="ion-padding no-padding-top">
|
||||
<!-- Premium PWA Android Install Banner -->
|
||||
<div class="pwa-android-banner glass ion-margin-bottom" *ngIf="showAndroidBanner()">
|
||||
<div class="banner-inner">
|
||||
<div class="banner-icon-wrapper">
|
||||
<ion-icon name="cloud-download-outline" class="banner-icon"></ion-icon>
|
||||
</div>
|
||||
<div class="banner-text-wrapper">
|
||||
<h3 class="banner-title outfit-font">Installa la nostra App!</h3>
|
||||
<p class="banner-subtitle outfit-font">
|
||||
Accedi a tutti i canti all'istante, anche offline, direttamente dalla tua home screen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<ion-button fill="solid" size="small" color="secondary" class="install-action-btn outfit-font" (click)="installAndroidPwa()">
|
||||
Installa
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" class="dismiss-action-btn" (click)="dismissAndroidBanner($event)">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
|
||||
<div class="loading-wrapper">
|
||||
<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>
|
||||
@@ -281,9 +453,9 @@
|
||||
|
||||
<div class="item-wrapper">
|
||||
<!-- Selection Area -->
|
||||
<div class="selection-column" (click)="playlistService.toggleSongSelection(canto.id); $event.stopPropagation()">
|
||||
<div class="selection-column" (click)="toggleSongSelection(canto.id); $event.stopPropagation()">
|
||||
<span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)">
|
||||
{{ canto.id.startsWith('my_') ? 'M' : canto.id_canti }}
|
||||
{{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -294,10 +466,28 @@
|
||||
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary); display: flex; align-items: center; flex-wrap: wrap; gap: 4px;">
|
||||
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span>
|
||||
<span>{{ canto.titolo }}</span>
|
||||
<span *ngIf="canto.nonValidato" class="non-validato-badge">Non Validato</span>
|
||||
<span *ngIf="canto.nonValidato && !canto.id.startsWith('my_')" class="non-validato-badge remote-badge">Remoto</span>
|
||||
<span *ngIf="canto.id.startsWith('my_')" class="non-validato-badge mio-badge">Mio</span>
|
||||
</h2>
|
||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
||||
</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) }}
|
||||
@@ -349,8 +539,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (only for local songs) -->
|
||||
<div *ngIf="canto.id.startsWith('my_')" class="delete-section" (click)="$event.stopPropagation()">
|
||||
<!-- 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>
|
||||
@@ -376,25 +566,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
|
||||
<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.isPlayerSupported() && youtubePlayerService.currentCantoId()" class="ion-no-border">
|
||||
<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="play-skip-back-sharp"></ion-icon>
|
||||
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<ion-button fill="clear" color="secondary" (click)="youtubePlayerService.isPlaying() ? stopVideo($event) : playVideo($event, youtubePlayerService.currentCantoId()!)" class="play-btn">
|
||||
<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()"
|
||||
@@ -403,35 +597,55 @@
|
||||
(ionKnobMoveStart)="onSeekStart()"
|
||||
(ionKnobMoveEnd)="onSeekEnd()"
|
||||
color="secondary"
|
||||
class="global-range">
|
||||
class="global-range"
|
||||
style="margin: 0; padding: 0 4px;">
|
||||
</ion-range>
|
||||
|
||||
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="play-skip-forward-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: 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="medium" (click)="stopVideo($event)" class="close-btn">
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
<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>
|
||||
|
||||
<!-- Premium PWA iOS Tooltip -->
|
||||
<div class="pwa-ios-tooltip-wrapper" [class.has-player]="youtubePlayerService.currentCantoId()" *ngIf="showIosTooltip()">
|
||||
<div class="pwa-ios-tooltip glass">
|
||||
<div class="tooltip-header">
|
||||
<span class="tooltip-title outfit-font">Installa su iPhone</span>
|
||||
<ion-button fill="clear" size="small" class="dismiss-btn" (click)="dismissIosTooltip($event)">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
<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>
|
||||
<div class="tooltip-body">
|
||||
<p class="tooltip-text outfit-font">
|
||||
Tocca il pulsante Condividi <span class="safari-icon-inline"><ion-icon name="share-outline"></ion-icon></span> in basso e seleziona <strong>"Aggiungi a schermata Home"</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="tooltip-arrow"></div>
|
||||
|
||||
<p class="car-mode-subtext outfit-font" (click)="isBlackOverlayDismissed.set(true)">Tocca lo sfondo o l'icona per sbloccare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
+656
-57
@@ -48,32 +48,327 @@
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.voice-search-btn {
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
.adv-search-btn, .voice-search-btn {
|
||||
--padding-start: 6px;
|
||||
--padding-end: 6px;
|
||||
margin: 0;
|
||||
height: 44px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-search-btn.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-search-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.toggle-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.song-count-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 8px;
|
||||
border-radius: 12px;
|
||||
height: 30px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.advanced-search-link, .clear-advanced-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--ion-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
padding: 4px 5px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover, &:active {
|
||||
opacity: 1;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.12);
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inline-clear-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-danger, #ff4961);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clear-advanced-link {
|
||||
color: var(--ion-color-danger, #ff4961);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover, &:active {
|
||||
background: rgba(255, 73, 97, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions-row {
|
||||
.advanced-search-panel {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
backdrop-filter: blur(12px);
|
||||
animation: advSearchFadeIn 0.25s ease-out;
|
||||
|
||||
.adv-search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.adv-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.adv-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.adv-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
gap: 12px;
|
||||
padding: 8px 4px 8px 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 0 10px;
|
||||
height: 38px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
// Hide scrollbar but keep functionality
|
||||
&:focus-within {
|
||||
border-color: var(--ion-color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.adv-input-icon {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.adv-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.adv-clear-icon {
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-mic-btn {
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
margin: 0 0 0 2px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes advSearchFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.advanced-search-link {
|
||||
color: var(--ion-color-secondary) !important;
|
||||
}
|
||||
|
||||
.clear-advanced-link {
|
||||
color: var(--ion-color-danger, #d32f2f) !important;
|
||||
}
|
||||
|
||||
.advanced-search-panel {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
|
||||
.adv-input-group {
|
||||
.adv-label {
|
||||
color: var(--ion-color-secondary, #d96b00) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.adv-input-wrapper {
|
||||
background: #f4f5f8 !important;
|
||||
border: 1px solid #c0c4cc !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--ion-color-secondary, #d96b00) !important;
|
||||
box-shadow: 0 0 0 2px rgba(217, 107, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.adv-input-icon {
|
||||
color: var(--ion-color-secondary, #d96b00) !important;
|
||||
}
|
||||
|
||||
.adv-input {
|
||||
color: #000000 !important;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #666666 !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-clear-icon {
|
||||
color: #666666 !important;
|
||||
|
||||
&:hover {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(12px);
|
||||
margin-top: 4px;
|
||||
|
||||
.filter-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
|
||||
.filter-row-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-color-secondary);
|
||||
width: 68px;
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-row-items {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
.empty-playlist-text {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
.song-count-card {
|
||||
display: flex;
|
||||
@@ -84,18 +379,17 @@
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
height: 32px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-button.filter-chip {
|
||||
--border-radius: 20px;
|
||||
@@ -116,27 +410,85 @@
|
||||
padding: 4px;
|
||||
margin-right: -8px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
ion-toolbar.playlist-toolbar {
|
||||
--padding-top: 0px;
|
||||
--padding-bottom: 8px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--min-height: auto;
|
||||
}
|
||||
|
||||
.playlist-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
.playlist-duration-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 10px;
|
||||
border-radius: 12px;
|
||||
height: 30px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
margin: 0 8px;
|
||||
flex-shrink: 1;
|
||||
|
||||
.playlist-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-pill {
|
||||
margin: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.transparent-list {
|
||||
background: transparent !important;
|
||||
padding-bottom: 120px; // Spazio per il player fisso
|
||||
@@ -148,10 +500,9 @@ ion-item.glass {
|
||||
--padding-start: 16px;
|
||||
--inner-padding-end: 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
--background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -161,28 +512,87 @@ ion-title {
|
||||
letter-spacing: 1px;
|
||||
color: var(--ion-color-secondary);
|
||||
padding-inline: 0;
|
||||
|
||||
.add-btn, .settings-btn {
|
||||
--color: var(--ion-color-secondary);
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
ion-header {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
ion-buttons[slot="end"] {
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.update-btn, .add-btn, .settings-btn {
|
||||
--color: var(--ion-color-secondary);
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin-left: -4px;
|
||||
margin-right: 0;
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
animation: pulse-update 2.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-update {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 6px rgba(var(--ion-color-secondary-rgb), 0.6));
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 375px) {
|
||||
ion-buttons[slot="end"] {
|
||||
gap: 0px;
|
||||
}
|
||||
.update-btn, .add-btn, .settings-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin-left: -5px;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
.settings-btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
ion-title {
|
||||
.header-logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 0 4px 24px; // Reduced padding to bring search bar closer
|
||||
gap: 8px;
|
||||
padding: 10px 0 10px 8px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-text-group {
|
||||
@@ -193,24 +603,26 @@ ion-title {
|
||||
line-height: 1;
|
||||
|
||||
.app-name {
|
||||
font-size: 1.4rem;
|
||||
font-size: clamp(1.1rem, 3.8vw, 1.25rem);
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.settings-btn {
|
||||
margin-right: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.special-list-banner {
|
||||
@@ -318,7 +730,7 @@ ion-title {
|
||||
.search-wrapper-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px 8px 16px;
|
||||
padding: 6px 16px 8px 16px; // Added slight top padding for search bar breathing room
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -368,6 +780,30 @@ ion-title {
|
||||
}
|
||||
}
|
||||
|
||||
.non-validati-add-icon {
|
||||
font-size: 1.1rem;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.non-validati-add-icon {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
.playlist-name-input {
|
||||
color: #000000 !important;
|
||||
&::placeholder {
|
||||
color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Item Layout
|
||||
.custom-item {
|
||||
--padding-start: 16px;
|
||||
@@ -408,7 +844,8 @@ ion-title {
|
||||
width: 100%;
|
||||
|
||||
.selection-column {
|
||||
padding: 10px 14px 10px 0;
|
||||
padding: 12px 18px 12px 12px;
|
||||
margin-left: -12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -425,6 +862,11 @@ ion-title {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
display: flex;
|
||||
@@ -653,6 +1095,30 @@ ion-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-name-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
margin: 0 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8rem;
|
||||
width: 120px;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--ion-color-secondary);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 0 6px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.small-action-btn {
|
||||
@@ -707,7 +1173,6 @@ ion-title {
|
||||
&.glass {
|
||||
background: rgba(26, 26, 46, 0.95) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@@ -723,25 +1188,14 @@ ion-title {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.play-btn {
|
||||
.play-btn, .skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,12 +1400,24 @@ ion-title {
|
||||
margin-left: 6px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
&.mio-badge {
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
color: var(--ion-color-secondary);
|
||||
border-color: rgba(var(--ion-color-secondary-rgb), 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) .non-validato-badge {
|
||||
background: rgba(231, 76, 60, 0.1) !important;
|
||||
color: #c0392b !important;
|
||||
border-color: #c0392b !important;
|
||||
|
||||
&.mio-badge {
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.1) !important;
|
||||
color: var(--ion-color-secondary-shade, #007bb6) !important;
|
||||
border-color: var(--ion-color-secondary-shade, #007bb6) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1273,3 +1739,136 @@ ion-title {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-playlist-container {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty-playlist-instruction {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: left;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.08);
|
||||
border: 1px dashed rgba(var(--ion-color-secondary-rgb), 0.35);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.empty-playlist-instruction {
|
||||
color: #000000 !important;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.12) !important;
|
||||
border-color: var(--ion-color-secondary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* BLACK SCREEN OVERLAY FOR CAR MODE */
|
||||
.black-screen-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000000 !important;
|
||||
z-index: 9999999 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
|
||||
.black-screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
gap: 12px;
|
||||
|
||||
.car-mode-icon {
|
||||
font-size: 3.5rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.car-mode-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.car-mode-subtext {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.car-mode-canto-info {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.car-mode-canto-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.car-mode-canto-author {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.car-mode-progress {
|
||||
margin-top: 8px;
|
||||
|
||||
.car-mode-time {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.car-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 15px;
|
||||
|
||||
.car-mode-btn {
|
||||
--color: rgba(255, 255, 255, 0.25);
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
margin: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
&:active {
|
||||
--color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1474
-171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable()
|
||||
export class ApiAuthInterceptor implements HttpInterceptor {
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (req.url.includes('api.canticristiani.it')) {
|
||||
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
|
||||
const authReq = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: authHeader
|
||||
}
|
||||
});
|
||||
return next.handle(authReq);
|
||||
}
|
||||
return next.handle(req);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Previous line -->
|
||||
<div *ngIf="displayLines().prev" class="prev-line">
|
||||
<ng-container *ngIf="showChords(); else prevText">
|
||||
<span *ngFor="let seg of displayLines().prev!.segments" class="chord-segment">
|
||||
<span *ngFor="let seg of displayLines().prev!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().prev!.segments, i)">
|
||||
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||
<span class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- Active line -->
|
||||
<div *ngIf="displayLines().current" class="active-line outfit-font">
|
||||
<ng-container *ngIf="showChords(); else currentText">
|
||||
<span *ngFor="let seg of displayLines().current!.segments" class="chord-segment">
|
||||
<span *ngFor="let seg of displayLines().current!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().current!.segments, i)">
|
||||
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||
<span class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Next line -->
|
||||
<div *ngIf="displayLines().next" class="next-line">
|
||||
<ng-container *ngIf="showChords(); else nextText">
|
||||
<span *ngFor="let seg of displayLines().next!.segments" class="chord-segment">
|
||||
<span *ngFor="let seg of displayLines().next!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().next!.segments, i)">
|
||||
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||
<span class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
min-height: 0.5em;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
|
||||
.active-line .chord {
|
||||
@@ -74,6 +75,9 @@
|
||||
|
||||
.seg-text {
|
||||
white-space: pre-wrap;
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
|
||||
@@ -16,14 +16,42 @@ export class DisplayPage implements OnInit, OnDestroy {
|
||||
public showChords = signal<boolean>(false);
|
||||
public fontSize = signal<number>(1.0);
|
||||
public currentLineIndex = signal<number>(0);
|
||||
public transposeAmount = signal<number>(0);
|
||||
|
||||
public parsedSections = computed<ParsedSection[]>(() => {
|
||||
const c = this.canto();
|
||||
if (!c) return [];
|
||||
if (this.showChords() && c.accordi) {
|
||||
return this.lyricsParser.parseAccordi(c.accordi);
|
||||
|
||||
let sections: ParsedSection[];
|
||||
const hasChordsInText = !c.accordi && c.testo?.includes('[');
|
||||
|
||||
if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) {
|
||||
sections = this.lyricsParser.parseAccordi(c.accordi || c.testo);
|
||||
} else {
|
||||
sections = this.lyricsParser.parseText(c.testo);
|
||||
}
|
||||
return this.lyricsParser.parseText(c.testo);
|
||||
|
||||
if (sections.length === 0 && c.testo) {
|
||||
const fallbackLines = c.testo.split('\n')
|
||||
.filter(l => l.trim().length > 0)
|
||||
.map(l => ({
|
||||
text: l.trim(),
|
||||
segments: [{ text: l.trim() }]
|
||||
}));
|
||||
|
||||
if (fallbackLines.length > 0) {
|
||||
sections = [{
|
||||
type: 'verse',
|
||||
lines: fallbackLines
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (this.showChords()) {
|
||||
return this.lyricsParser.transposeSections(sections, this.transposeAmount());
|
||||
}
|
||||
|
||||
return sections;
|
||||
});
|
||||
|
||||
/** Get the current line text and surrounding lines from flat index */
|
||||
@@ -54,7 +82,7 @@ export class DisplayPage implements OnInit, OnDestroy {
|
||||
|
||||
private route = inject(ActivatedRoute);
|
||||
private cantiService = inject(CantiService);
|
||||
private lyricsParser = inject(LyricsParserService);
|
||||
public lyricsParser = inject(LyricsParserService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
|
||||
constructor() {
|
||||
@@ -94,6 +122,7 @@ export class DisplayPage implements OnInit, OnDestroy {
|
||||
}
|
||||
if (event.data.type === 'SYNC_CANTO') {
|
||||
this.activeSongId.set(event.data.id);
|
||||
this.transposeAmount.set(0);
|
||||
}
|
||||
if (event.data.type === 'SYNC_CHORDS') {
|
||||
this.showChords.set(event.data.showChords);
|
||||
@@ -101,6 +130,9 @@ export class DisplayPage implements OnInit, OnDestroy {
|
||||
if (event.data.type === 'SYNC_FONT') {
|
||||
this.fontSize.set(event.data.fontSize);
|
||||
}
|
||||
if (event.data.type === 'SYNC_TRANSPOSE') {
|
||||
this.transposeAmount.set(event.data.amount);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,70 @@
|
||||
<ion-header [translucent]="true" class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient top-toolbar">
|
||||
<ion-buttons slot="end">
|
||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
</div>
|
||||
<ion-button fill="clear" (click)="toggleChords()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only"
|
||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||
[color]="showChords() ? 'secondary' : 'medium'"
|
||||
style="font-size: 1.3rem;">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title class="outfit-font wrapped-title">
|
||||
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti">
|
||||
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }}
|
||||
<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="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap;">
|
||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||
<span 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>
|
||||
<span *ngIf="canto()?.nonValidato" class="non-validato-badge">Non Validato</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-title>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
|
||||
<!-- Audio Toolbar Removed -->
|
||||
@@ -36,63 +74,71 @@
|
||||
<div class="lyrics-container"
|
||||
[style.fontSize.rem]="fontSize()"
|
||||
[class.full-screen-container]="settingsService.fullscreenMode()"
|
||||
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
|
||||
(touchstart)="onTouchStart($event)"
|
||||
(touchmove)="onTouchMove($event)"
|
||||
(touchend)="onTouchEnd()">
|
||||
|
||||
|
||||
<!-- Landscape Side Controls (Scrollable) -->
|
||||
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
|
||||
<div class="side-scroll-container">
|
||||
<!-- Autoscroll group in Landscape Side -->
|
||||
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()" style="gap: 4px; padding: 4px 0; background: rgba(255,255,255,0.05); border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); width: 44px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10" style="height: 32px; margin: 0;">
|
||||
<ion-icon name="add" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
</ion-button>
|
||||
<div (click)="toggleAutoscroll()" style="cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;">
|
||||
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'" style="font-size: 1.4rem;"></ion-icon>
|
||||
<span style="font-size: 0.65rem; font-weight: 700; color: var(--ion-color-secondary);">V{{ autoscrollSpeed() }}</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1" style="height: 32px; margin: 0;">
|
||||
<ion-icon name="remove" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="side-group equidistant-group">
|
||||
<!-- Navigation -->
|
||||
<ion-button fill="clear" (click)="restart()">
|
||||
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
|
||||
<!-- Navigation Group -->
|
||||
<div class="side-group">
|
||||
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
|
||||
prv
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="side-group">
|
||||
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
|
||||
<ion-icon name="chevron-up" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
|
||||
<ion-icon name="chevron-down" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<!-- Zoom -->
|
||||
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= 5.0">
|
||||
<ion-icon name="add-circle-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= 0.6">
|
||||
<ion-icon name="remove-circle-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<!-- Karaoke Toggle (Moved down) -->
|
||||
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'">
|
||||
<ion-icon [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<!-- Youtube Link -->
|
||||
<ion-button *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" (click)="openYoutube()">
|
||||
<ion-icon name="logo-youtube" color="danger"></ion-icon>
|
||||
<ion-icon slot="icon-only" name="chevron-up" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="side-group">
|
||||
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
|
||||
<ion-icon slot="icon-only" name="chevron-down" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="side-group">
|
||||
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="landscape-playlist-btn next-btn">
|
||||
nxt
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Group -->
|
||||
<div class="side-group">
|
||||
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
|
||||
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
<div class="side-indicator" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">
|
||||
<ion-icon name="search-outline" color="secondary" style="font-size: 1.0rem;"></ion-icon>
|
||||
<span class="side-val" style="font-size: 0.7rem; font-weight: 700; color: var(--ion-color-secondary);">{{ fontSize().toFixed(1) }}</span>
|
||||
</div>
|
||||
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
|
||||
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Autoscroll Standard Group -->
|
||||
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()">
|
||||
<ion-button fill="clear" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
|
||||
<ion-icon slot="icon-only" name="remove"></ion-icon>
|
||||
</ion-button>
|
||||
<div class="side-indicator" (click)="toggleAutoscroll()">
|
||||
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
|
||||
<span class="side-val">V{{ autoscrollSpeed() }}</span>
|
||||
</div>
|
||||
<ion-button fill="clear" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
|
||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections rendering -->
|
||||
<div class="lyrics-view">
|
||||
<div class="lyrics-view" (click)="handleLyricsClick($event)">
|
||||
<div *ngFor="let section of parsedSections(); let si = index"
|
||||
class="section"
|
||||
[class.chorus]="section.type === 'chorus'"
|
||||
@@ -107,8 +153,8 @@
|
||||
[class.active]="isActiveLine(si, li)">
|
||||
|
||||
<!-- Chord mode: show chords above text -->
|
||||
<ng-container *ngIf="showChords(); else textOnly">
|
||||
<span *ngFor="let seg of line.segments" class="chord-segment">
|
||||
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
|
||||
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||
<span class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
@@ -120,49 +166,57 @@
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima sullo schermo -->
|
||||
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer class="ion-no-border">
|
||||
<!-- Audio Player Toolbar -->
|
||||
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
|
||||
<!-- Audio Player / Playlist Navigation Toolbar -->
|
||||
<ion-toolbar class="global-player-toolbar glass">
|
||||
<div class="player-content">
|
||||
<div class="controls-row">
|
||||
<ion-button fill="clear" color="secondary" (click)="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="play-skip-back-sharp"></ion-icon>
|
||||
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
|
||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<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">
|
||||
class="global-range"
|
||||
style="margin: 0; padding: 0 4px;">
|
||||
</ion-range>
|
||||
|
||||
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
||||
</ion-button>
|
||||
<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="medium" (click)="stopVideo($event)" class="close-btn">
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
<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>
|
||||
|
||||
<!-- Voice Activity Visualizer (Slim overlay) -->
|
||||
<div class="transcript-area slim" *ngIf="settingsService.enableAcousticAutoscroll() && audioEngine.isListening()">
|
||||
<div class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)"></div>
|
||||
</div>
|
||||
|
||||
<ion-toolbar class="bg-gradient slim-toolbar">
|
||||
<div class="slim-controls">
|
||||
<!-- Autoscroll Standard in Portrait Footer -->
|
||||
@@ -179,27 +233,19 @@
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Karaoke Toggle in Portrait -->
|
||||
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" size="small" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'" class="mic-btn-portrait">
|
||||
<ion-icon slot="icon-only" [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
|
||||
<!-- Camera Head Gestures Toggle in Portrait -->
|
||||
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
|
||||
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
|
||||
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
|
||||
<!-- Transposition (Only in chords mode) -->
|
||||
<div class="group" *ngIf="showChords()">
|
||||
<ion-button fill="clear" size="small" (click)="transposeDown()">
|
||||
<ion-icon slot="icon-only" name="remove"></ion-icon>
|
||||
</ion-button>
|
||||
<div class="transpose-indicator">
|
||||
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
||||
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
|
||||
<div class="camera-indicator" *ngIf="enableCameraNavigation()" [class.tilted]="faceDetector.isTilted()">
|
||||
<ion-icon name="person-outline"
|
||||
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
|
||||
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<!-- 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>
|
||||
@@ -212,41 +258,100 @@
|
||||
</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() <= 0.6">
|
||||
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
|
||||
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= 5.0">
|
||||
<div class="zoom-indicator">
|
||||
<ion-icon name="search-outline" color="secondary"></ion-icon>
|
||||
<span class="val">{{ fontSize().toFixed(1) }}</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
|
||||
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="group" *ngIf="connectivityService.isOnline()">
|
||||
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()">
|
||||
<!-- YouTube Link in Portrait Footer -->
|
||||
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
|
||||
<ion-button fill="clear" size="small" (click)="openYoutube()">
|
||||
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
<!-- Vertical Sensitivity Slider Overlay -->
|
||||
<div class="mic-sensitivity-overlay" *ngIf="settingsService.enableAcousticAutoscroll() && showSensitivitySlider() && audioEngine.isListening()">
|
||||
<div class="slider-card glass">
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
<!-- 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 class="slider-wrapper"
|
||||
(touchstart)="handleSensitivityTouch($event)"
|
||||
(touchmove)="handleSensitivityTouch($event)">
|
||||
<div class="custom-vertical-slider">
|
||||
<div class="slider-track"></div>
|
||||
<div class="slider-fill" [style.height.%]="(audioEngine.sensitivity() - 50) * 2"></div>
|
||||
<div class="slider-knob" [style.bottom.%]="(audioEngine.sensitivity() - 50) * 2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sensitivity-label outfit-font">{{ audioEngine.sensitivity() }}%</span>
|
||||
|
||||
<p class="car-mode-subtext outfit-font" (click)="deactivateBlackScreen()">Tocca lo sfondo o l'icona per sbloccare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
|
||||
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
|
||||
}
|
||||
|
||||
.canto-title {
|
||||
transition: font-size 0.15s ease;
|
||||
&.small-title {
|
||||
font-size: 1.12rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.outfit-font {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
@@ -15,31 +22,28 @@
|
||||
|
||||
// Force left alignment in Ionic toolbar
|
||||
ion-title {
|
||||
padding-inline: 8px;
|
||||
padding-inline-start: 56px; // Clear the back button on iOS/Apple devices
|
||||
padding-inline-end: 8px;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.title-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
|
||||
.canto-number {
|
||||
font-size: 0.85em;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
padding: 2px 10px;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.1);
|
||||
color: var(--ion-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--ion-color-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -55,11 +59,142 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
--color: var(--ion-color-secondary);
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.title-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
font-size: 1.15rem; // Fisso: non influenzato dallo zoom del testo
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
|
||||
.song-author {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-weight: 400;
|
||||
display: inline-block;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
margin: 0;
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive scaling based on viewport width
|
||||
@media (max-width: 480px) {
|
||||
gap: 4px;
|
||||
|
||||
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 3px;
|
||||
height: 22px;
|
||||
gap: 2px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
--padding-start: 0px;
|
||||
--padding-end: 0px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
gap: 2px;
|
||||
|
||||
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 1px 2px;
|
||||
height: 20px;
|
||||
gap: 1px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
--padding-start: 0px;
|
||||
--padding-end: 0px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content and Lyrics
|
||||
@@ -104,25 +239,14 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.play-btn {
|
||||
.play-btn, .skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +267,7 @@
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.2rem;
|
||||
position: relative;
|
||||
|
||||
&.chorus {
|
||||
@@ -159,18 +283,22 @@
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.35;
|
||||
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;
|
||||
|
||||
&.active {
|
||||
color: var(--ion-color-secondary);
|
||||
@@ -186,16 +314,24 @@
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.2em;
|
||||
|
||||
&.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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,13 +389,44 @@
|
||||
|
||||
ion-icon { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.zoom-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
|
||||
ion-icon { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.camera-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
padding: 0 4px;
|
||||
transition: color 0.15s ease-out;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.1rem;
|
||||
transition: transform 0.15s ease-out;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.tilted {
|
||||
color: var(--ion-color-success, #2ed573);
|
||||
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript area
|
||||
.transcript-area {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.2);
|
||||
|
||||
.energy-bar {
|
||||
height: 100%;
|
||||
@@ -269,8 +436,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen and Orientation
|
||||
@media (orientation: landscape) {
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.9); opacity: 0.6; }
|
||||
50% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(0.9); opacity: 0.6; }
|
||||
}
|
||||
|
||||
:host(.landscape-active) {
|
||||
// Always hide footer in landscape as we have side controls
|
||||
ion-footer {
|
||||
display: none !important;
|
||||
@@ -279,33 +451,30 @@
|
||||
.lyrics-container {
|
||||
height: 100%;
|
||||
padding-top: 4px; // Minimized
|
||||
padding-bottom: 8px !important;
|
||||
padding-right: 90px !important; // More room for side controls and zoom
|
||||
}
|
||||
|
||||
.lyrics-view {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
ion-content {
|
||||
--offset-bottom: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
ion-content.full-screen-content {
|
||||
--offset-bottom: 0px !important;
|
||||
|
||||
@media (orientation: portrait) {
|
||||
--offset-bottom: 48px !important; // Footer height
|
||||
}
|
||||
}
|
||||
|
||||
.landscape-side-controls {
|
||||
display: none !important; // Strict hidden in portrait
|
||||
|
||||
@media (orientation: landscape) {
|
||||
:host(.landscape-active) & {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 44px !important; // Align with header
|
||||
top: 56px !important; // Align below header
|
||||
bottom: 0;
|
||||
width: 60px;
|
||||
width: 50px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -317,8 +486,10 @@ ion-content.full-screen-content {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 0;
|
||||
gap: 16px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 12px;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
@@ -326,23 +497,82 @@ ion-content.full-screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px; // Reduced for a more compact layout
|
||||
padding-bottom: 20px;
|
||||
gap: 8px; // Reduced for a more compact layout
|
||||
padding-bottom: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
margin: 0 2px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 6px;
|
||||
|
||||
ion-button {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin: 0;
|
||||
height: 48px;
|
||||
ion-icon { font-size: 1.8rem; }
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
ion-icon { font-size: 1.5rem; }
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-range-container {
|
||||
height: 120px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.vertical-range {
|
||||
transform: rotate(-90deg);
|
||||
width: 120px;
|
||||
--bar-height: 4px;
|
||||
--knob-size: 14px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.side-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.0rem;
|
||||
}
|
||||
|
||||
&.tilted {
|
||||
color: var(--ion-color-success, #2ed573);
|
||||
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.side-val {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.side-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
ion-content.full-screen-content {
|
||||
--offset-bottom: 48px !important; // Footer height
|
||||
}
|
||||
|
||||
:host(.landscape-active) ion-content.full-screen-content {
|
||||
--offset-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.mic-sensitivity-overlay {
|
||||
position: fixed;
|
||||
right: 15px;
|
||||
@@ -351,7 +581,7 @@ ion-content.full-screen-content {
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
|
||||
@media (orientation: landscape) {
|
||||
:host(.landscape-active) & {
|
||||
right: 80px; // Prossimo ai controlli laterali
|
||||
}
|
||||
|
||||
@@ -450,6 +680,109 @@ ion-content.full-screen-content {
|
||||
}
|
||||
}
|
||||
|
||||
.voice-threshold-overlay {
|
||||
position: fixed;
|
||||
left: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
|
||||
.slider-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 6px 12px 6px;
|
||||
background: rgba(18, 18, 18, 0.85) !important;
|
||||
backdrop-filter: blur(25px);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(46, 213, 115, 0.4);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
gap: 8px;
|
||||
animation: slideInLeft 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.close-slider-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin: 0;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
--color: var(--ion-color-secondary);
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
height: 180px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.custom-vertical-slider {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #2ed573, #7bed9f);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 10px rgba(46, 213, 115, 0.3);
|
||||
transition: height 0.05s linear;
|
||||
}
|
||||
|
||||
.slider-knob {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #2ed573;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 0 15px rgba(46, 213, 115, 0.5);
|
||||
border: 2px solid #fff;
|
||||
transition: bottom 0.05s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.sensitivity-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
color: #2ed573;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-autoscroll-bar {
|
||||
position: fixed;
|
||||
bottom: 120px;
|
||||
@@ -522,6 +855,31 @@ ion-content.full-screen-content {
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.title-main .song-author {
|
||||
color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.slim-toolbar {
|
||||
--background: #ffffff !important;
|
||||
background: #ffffff !important;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.2) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.landscape-side-controls {
|
||||
--background: #ffffff !important;
|
||||
background: #ffffff !important;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.landscape-audio-player {
|
||||
--background: #ffffff !important;
|
||||
background: #ffffff !important;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.slim-controls .group {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
@@ -553,3 +911,223 @@ ion-content.full-screen-content {
|
||||
color: #c0392b !important;
|
||||
border-color: #c0392b !important;
|
||||
}
|
||||
|
||||
:host-context(.md) {
|
||||
.top-toolbar ion-title {
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.autoscroll-sync-status {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(212, 136, 0, 0.18) !important;
|
||||
border: 1px solid rgba(253, 203, 110, 0.5);
|
||||
padding: 6px 14px;
|
||||
border-radius: 16px;
|
||||
z-index: 999;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
animation: slideDownSync 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #fdcb6e;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.spin-animation {
|
||||
animation: spinSync 2s linear infinite;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinSync {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes slideDownSync {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.camera-preview-floating {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 15px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.4);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(18, 18, 18, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
|
||||
:host(.landscape-active) & {
|
||||
bottom: 15px;
|
||||
right: 80px;
|
||||
}
|
||||
|
||||
video {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.camera-preview-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.angle-indicator {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.tilted {
|
||||
color: #2ed573;
|
||||
text-shadow: 0 0 5px #2ed573;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.black-screen-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000000 !important;
|
||||
z-index: 9999999 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
|
||||
.black-screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
gap: 12px;
|
||||
|
||||
.car-mode-icon {
|
||||
font-size: 3.5rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.car-mode-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.car-mode-subtext {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.car-mode-canto-info {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.car-mode-canto-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.car-mode-canto-author {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.car-mode-progress {
|
||||
margin-top: 8px;
|
||||
|
||||
.car-mode-time {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.car-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 15px;
|
||||
|
||||
.car-mode-btn {
|
||||
--color: rgba(255, 255, 255, 0.25);
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
margin: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
&:active {
|
||||
--color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.landscape-playlist-btn {
|
||||
writing-mode: vertical-rl;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 1px;
|
||||
height: auto;
|
||||
min-height: 75px;
|
||||
width: 30px;
|
||||
margin: 0;
|
||||
--padding-start: 2px;
|
||||
--padding-end: 2px;
|
||||
color: var(--ion-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
+925
-115
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
||||
</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>
|
||||
@@ -14,8 +17,12 @@
|
||||
|
||||
<ion-content class="bg-gradient">
|
||||
<div class="ion-padding">
|
||||
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0">
|
||||
<p>Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
|
||||
<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)">
|
||||
@@ -24,7 +31,7 @@
|
||||
<ion-icon name="reorder-two-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<span class="canto-number">{{ song.id_canti }}</span>
|
||||
<span class="canto-number">{{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }}</span>
|
||||
<span class="song-title">
|
||||
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
|
||||
</span>
|
||||
|
||||
@@ -51,6 +51,42 @@ export class PlaylistPage {
|
||||
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()];
|
||||
@@ -60,6 +96,15 @@ export class PlaylistPage {
|
||||
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
|
||||
getPlaylistSongSettings(): any {
|
||||
const activeId = this.playlistService.activePlaylistId();
|
||||
if (activeId) {
|
||||
const pl = this.playlistService.playlists().find(p => p.id === activeId);
|
||||
return pl ? pl.songSettings : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async savePlaylist() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Salva Playlist',
|
||||
@@ -81,7 +126,7 @@ export class PlaylistPage {
|
||||
handler: (data) => {
|
||||
if (data.name) {
|
||||
this.savedPlaylistName = data.name;
|
||||
this.playlistService.savePlaylist(data.name, this.localSongs.map(s => s.id));
|
||||
this.playlistService.savePlaylist(data.name, this.localSongs.map(s => s.id), this.getPlaylistSongSettings());
|
||||
this.showToast('Playlist salvata!');
|
||||
this.router.navigate(['/settings']);
|
||||
return true;
|
||||
@@ -117,8 +162,9 @@ export class PlaylistPage {
|
||||
if (data.name) {
|
||||
this.savedPlaylistName = data.name;
|
||||
const ids = this.localSongs.map(s => s.id);
|
||||
await this.playlistService.savePlaylist(data.name, ids);
|
||||
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name);
|
||||
const songSettings = this.getPlaylistSongSettings();
|
||||
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
||||
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name, songSettings);
|
||||
this.showToast('Playlist salvata!');
|
||||
return true;
|
||||
}
|
||||
@@ -135,7 +181,8 @@ export class PlaylistPage {
|
||||
|
||||
const ids = this.localSongs.map(s => s.id);
|
||||
const name = this.savedPlaylistName || 'Playlist Condivisa';
|
||||
this.qrCodeImage = await this.playlistService.generateQR(ids, name);
|
||||
const songSettings = this.getPlaylistSongSettings();
|
||||
this.qrCodeImage = await this.playlistService.generateQR(ids, name, songSettings);
|
||||
}
|
||||
|
||||
downloadQR() {
|
||||
@@ -157,7 +204,8 @@ export class PlaylistPage {
|
||||
async shareQR() {
|
||||
if (!this.qrCodeImage) return;
|
||||
const name = this.savedPlaylistName || 'playlist';
|
||||
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name);
|
||||
const songSettings = this.getPlaylistSongSettings();
|
||||
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name, songSettings);
|
||||
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||
|
||||
try {
|
||||
@@ -166,7 +214,10 @@ export class PlaylistPage {
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
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',
|
||||
@@ -180,6 +231,13 @@ export class PlaylistPage {
|
||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
|
||||
});
|
||||
} else {
|
||||
// Copy to clipboard AND download QR!
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
this.showToast('Link copiato negli appunti! QR scaricato.');
|
||||
}
|
||||
} catch(e) {}
|
||||
this.downloadQR();
|
||||
}
|
||||
}
|
||||
@@ -208,4 +266,48 @@ export class PlaylistPage {
|
||||
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
|
||||
return info && info.num_canto ? info.num_canto.toString() : null;
|
||||
}
|
||||
|
||||
getMySongNumber(song: any): number {
|
||||
if (!song || !song.id) return 0;
|
||||
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
|
||||
return index !== -1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,20 @@
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding bg-gradient" [class.high-contrast-mode]="isHighContrast">
|
||||
<ion-content class="ion-padding bg-gradient"
|
||||
[class.high-contrast-mode]="isHighContrast"
|
||||
[class.drag-over]="isDraggingOver"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)">
|
||||
|
||||
<div class="drag-overlay" *ngIf="isDraggingOver">
|
||||
<div class="drag-message">
|
||||
<ion-icon name="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">
|
||||
@@ -18,17 +31,42 @@
|
||||
</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 cantiService.indiceLiturgico()" [value]="lit.id">
|
||||
<ion-select-option *ngFor="let lit of sortedLiturgico" [value]="lit.id">
|
||||
{{ lit.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
@@ -37,7 +75,7 @@
|
||||
<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 cantiService.indiceTematico()" [value]="tem.id">
|
||||
<ion-select-option *ngFor="let tem of sortedTematico" [value]="tem.id">
|
||||
{{ tem.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
@@ -47,9 +85,14 @@
|
||||
<!-- TOOLBARS -->
|
||||
<div class="toolbar-section">
|
||||
<div class="horizontal-toolbar">
|
||||
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
|
||||
<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>
|
||||
|
||||
@@ -83,34 +126,90 @@
|
||||
<!-- EDITOR AREA -->
|
||||
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title-group">
|
||||
<div class="editor-title-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>Editor Testo</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
|
||||
<ion-icon name="undo-outline"></ion-icon>
|
||||
|
||||
<!-- 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>
|
||||
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
||||
<ion-icon name="camera-outline"></ion-icon>
|
||||
<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>
|
||||
</div>
|
||||
<ion-item class="custom-input-item textarea-item">
|
||||
<ion-textarea
|
||||
#contentTextarea
|
||||
[(ngModel)]="content"
|
||||
placeholder="Scrivi o scansiona..."
|
||||
rows="18"
|
||||
class="content-textarea">
|
||||
</ion-textarea>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<!-- 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 / Link YouTube</ion-label>
|
||||
<ion-label position="stacked">Autore</ion-label>
|
||||
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
|
||||
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></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">
|
||||
@@ -121,8 +220,59 @@
|
||||
</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>
|
||||
|
||||
@@ -94,11 +94,14 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
|
||||
/* Textarea inside high contrast must have white background and black text */
|
||||
.content-textarea {
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
.editor-textarea-container {
|
||||
background: #ffffff !important;
|
||||
--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 */
|
||||
@@ -113,7 +116,8 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
|
||||
.propose-container {
|
||||
max-width: 800px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -266,30 +270,49 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
background: #ffffff !important;
|
||||
--background: #ffffff !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
.editor-textarea-container {
|
||||
background: #ffffff;
|
||||
|
||||
textarea {
|
||||
color: #000000;
|
||||
caret-color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.content-textarea {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
--color: #ffffff !important;
|
||||
.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;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--padding-top: 16px;
|
||||
--padding-bottom: 16px;
|
||||
min-height: 400px;
|
||||
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 {
|
||||
@@ -419,3 +442,404 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* DRAG AND DROP OVERLAY */
|
||||
.drag-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
|
||||
.drag-message {
|
||||
text-align: center;
|
||||
color: var(--ion-color-secondary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 3px dashed var(--ion-color-secondary);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
|
||||
ion-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.high-contrast :host ::ng-deep {
|
||||
.drag-overlay {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
.drag-message {
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border: 3px dashed #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* RESPONSIVE LAYOUT & ACTIVE PREVIEW PANE */
|
||||
.editor-preview-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
|
||||
@media (min-width: 992px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-column {
|
||||
@media (min-width: 992px) {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.preview-header {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
.preview-title {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--ion-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.preview-toggle-btn {
|
||||
--color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
ion-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
padding: 24px;
|
||||
min-height: 400px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.preview-song-header {
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 14px;
|
||||
|
||||
.preview-song-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px 0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
.preview-song-author {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-lyrics-container {
|
||||
font-size: 1.1rem;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
padding-right: 8px;
|
||||
scrollbar-width: thin;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +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;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('convertEnglishChordToItalian', () => {
|
||||
it('should convert simple English chords to Italian', () => {
|
||||
expect(component.convertEnglishChordToItalian('C')).toBe('DO');
|
||||
expect(component.convertEnglishChordToItalian('D')).toBe('RE');
|
||||
expect(component.convertEnglishChordToItalian('E')).toBe('MI');
|
||||
expect(component.convertEnglishChordToItalian('F')).toBe('FA');
|
||||
expect(component.convertEnglishChordToItalian('G')).toBe('SOL');
|
||||
expect(component.convertEnglishChordToItalian('A')).toBe('LA');
|
||||
expect(component.convertEnglishChordToItalian('B')).toBe('SI');
|
||||
});
|
||||
|
||||
it('should convert English chords with accidentals and modifiers', () => {
|
||||
expect(component.convertEnglishChordToItalian('C#m7')).toBe('DO#m7');
|
||||
expect(component.convertEnglishChordToItalian('Bb')).toBe('SIb');
|
||||
expect(component.convertEnglishChordToItalian('F#m')).toBe('FA#m');
|
||||
expect(component.convertEnglishChordToItalian('Faug')).toBe('FAaug');
|
||||
expect(component.convertEnglishChordToItalian('Fadd9')).toBe('FAadd9');
|
||||
});
|
||||
|
||||
it('should keep Italian chords unchanged', () => {
|
||||
expect(component.convertEnglishChordToItalian('DO')).toBe('DO');
|
||||
expect(component.convertEnglishChordToItalian('RE#m7')).toBe('RE#m7');
|
||||
expect(component.convertEnglishChordToItalian('FA#')).toBe('FA#');
|
||||
expect(component.convertEnglishChordToItalian('SIb')).toBe('SIb');
|
||||
});
|
||||
|
||||
it('should handle slash chords correctly', () => {
|
||||
expect(component.convertEnglishChordToItalian('C/E')).toBe('DO/MI');
|
||||
expect(component.convertEnglishChordToItalian('D/F#')).toBe('RE/FA#');
|
||||
expect(component.convertEnglishChordToItalian('F#m7/A#')).toBe('FA#m7/LA#');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeOcrChord', () => {
|
||||
it('should convert H to # when following a chord root', () => {
|
||||
expect(component.sanitizeOcrChord('CH')).toBe('C#');
|
||||
expect(component.sanitizeOcrChord('DHm7')).toBe('D#m7');
|
||||
expect(component.sanitizeOcrChord('FH#')).toBe('F#');
|
||||
expect(component.sanitizeOcrChord('FAH')).toBe('FA#');
|
||||
expect(component.sanitizeOcrChord('SOLH7')).toBe('SOL#7');
|
||||
expect(component.sanitizeOcrChord('FH#H')).toBe('F#');
|
||||
expect(component.sanitizeOcrChord('FH#H/AH#')).toBe('F#/A#');
|
||||
expect(component.sanitizeOcrChord('C#H-')).toBe('C#-');
|
||||
expect(component.sanitizeOcrChord('G#H#')).toBe('G#');
|
||||
});
|
||||
|
||||
it('should convert 0 to O for DO and SOL roots', () => {
|
||||
expect(component.sanitizeOcrChord('D0')).toBe('DO');
|
||||
expect(component.sanitizeOcrChord('D0#')).toBe('DO#');
|
||||
expect(component.sanitizeOcrChord('S0L')).toBe('SOL');
|
||||
});
|
||||
|
||||
it('should handle slash chords with sanitization', () => {
|
||||
expect(component.sanitizeOcrChord('CH/EH')).toBe('C#/E#');
|
||||
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
|
||||
});
|
||||
});
|
||||
|
||||
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
@@ -35,11 +35,11 @@
|
||||
<ion-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
|
||||
</ion-item>
|
||||
|
||||
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02);">
|
||||
<p class="outfit-font" style="font-size: 0.85rem; color: rgba(255,255,255,0.8); margin: 0 0 12px 0; line-height: 1.4;">
|
||||
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid var(--ion-border-color, rgba(255,255,255,0.08)); background: rgba(var(--ion-text-color-rgb, 255,255,255), 0.02);">
|
||||
<p class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0 0 12px 0; line-height: 1.4; opacity: 0.9;">
|
||||
Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
|
||||
</p>
|
||||
<ol class="outfit-font" style="font-size: 0.85rem; color: rgba(255,255,255,0.8); margin: 0; padding-left: 20px; line-height: 1.6;">
|
||||
<ol class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0; padding-left: 20px; line-height: 1.6; opacity: 0.9;">
|
||||
<li style="margin-bottom: 8px;">
|
||||
Tocca il pulsante di <strong>Condivisione</strong> <ion-icon name="share-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon> nella barra di navigazione inferiore di Safari.
|
||||
</li>
|
||||
@@ -53,6 +53,20 @@
|
||||
</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>
|
||||
@@ -62,14 +76,7 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="expand-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Nascondi Barre (Player)</h2>
|
||||
<p class="settings-item-subtitle">Modalità immersiva nel dettaglio canto</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.fullscreenMode()" (ionChange)="settingsService.toggleFullscreenMode()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
@@ -86,14 +93,7 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.autoAdvance()" (ionChange)="settingsService.toggleAutoAdvance()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="sunny-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Schermo Sempre Acceso</h2>
|
||||
<p class="settings-item-subtitle">Evita che lo schermo si spenga</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.keepScreenOn()" (ionChange)="settingsService.toggleKeepScreenOn()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="create-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
@@ -118,6 +118,14 @@
|
||||
</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">
|
||||
@@ -127,12 +135,36 @@
|
||||
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="mic-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-icon name="eye-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Autoscroll acustico</h2>
|
||||
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p>
|
||||
<h2 class="settings-item-title">Autoscroll visuale</h2>
|
||||
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle>
|
||||
<ion-toggle slot="end" [checked]="settingsService.enableVisualAutoscroll()" (ionChange)="settingsService.toggleVisualAutoscroll()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Avanzamento manuale a pagine</h2>
|
||||
<p class="settings-item-subtitle">I tasti Avanti/Indietro (Karaoke) voltano la pagina intera anziché riga per riga</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.karaokePageScrollMode()" (ionChange)="settingsService.toggleKaraokePageScrollMode()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||
<ion-icon name="phone-landscape-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Vista orizzontale per proiezione</h2>
|
||||
<p class="settings-item-subtitle">Adatta il layout in landscape per la proiezione (testo più grande, controlli dedicati)</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.landscapeProjectionEnabled()" (ionChange)="settingsService.toggleLandscapeProjectionEnabled()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<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>
|
||||
|
||||
@@ -144,7 +176,120 @@
|
||||
<h2 class="settings-item-title">Comunità</h2>
|
||||
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="settingsService.toggleComunitaEnabled()" color="secondary"></ion-toggle>
|
||||
<ion-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="onComunitaToggleChange($event)" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<!-- Community Details/Form when enabled -->
|
||||
<div class="ion-padding-horizontal ion-padding-bottom" *ngIf="settingsService.comunitaEnabled()" style="border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px;">
|
||||
<div *ngIf="comunitaService.comunitaCode()" class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
|
||||
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Comunità Attiva</div>
|
||||
<div style="font-size: 0.9rem; font-weight: 500; color: var(--ion-text-color); overflow: hidden; text-overflow: ellipsis;">{{ comunitaService.comunitaNome() }}</div>
|
||||
<div style="font-size: 0.75rem; opacity: 0.7; color: var(--ion-text-color); margin-top: 2px;">Codice: {{ comunitaService.comunitaCode() }}</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; flex-shrink: 0;">
|
||||
<ion-button fill="clear" size="small" color="secondary" (click)="editComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
|
||||
<ion-icon name="create-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" color="danger" (click)="removeComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
|
||||
<ion-icon name="trash-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!comunitaService.comunitaCode()" style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<p class="settings-item-subtitle outfit-font" style="margin: 0; font-size: 0.8rem; opacity: 0.85;">
|
||||
Inserisci il codice parrocchiale/comunità per scaricare i canti e le scalette dedicate:
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
|
||||
<input type="text"
|
||||
#comunitaCodeInput
|
||||
placeholder="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>
|
||||
|
||||
@@ -185,38 +330,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chord Notation Preference Section -->
|
||||
<div class="settings-group glass ion-margin-bottom">
|
||||
<div class="group-header ion-padding-start ion-padding-top">
|
||||
<h2 class="outfit-font settings-group-title">
|
||||
Notazione Accordi Preferita
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
|
||||
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
|
||||
<div class="filter-btn glass"
|
||||
[class.active-btn]="settingsService.chordNotationPreference() === 'diesis'"
|
||||
(click)="settingsService.setChordNotationPreference('diesis')">
|
||||
<span>Diesis (#)</span>
|
||||
</div>
|
||||
<div class="filter-btn glass"
|
||||
[class.active-btn]="settingsService.chordNotationPreference() === 'bemolle'"
|
||||
(click)="settingsService.setChordNotationPreference('bemolle')">
|
||||
<span>Bemolle (b)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="settings-group glass ion-margin-top" *ngIf="settingsService.showEditor()">
|
||||
<ion-item class="transparent-item" lines="none" (click)="myCantiService.sendAllMyCanti()" detail="true" button *ngIf="myCantiService.myCanti().length > 0">
|
||||
<ion-icon name="send-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Proponi i miei canti ({{ myCantiService.myCanti().length }})</h2>
|
||||
<p class="settings-item-subtitle">Invia a {{ contactEmail }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<div class="settings-group glass ion-margin-top">
|
||||
<ion-item class="transparent-item" lines="none" (click)="fullRefresh()" detail="true" button>
|
||||
<ion-icon name="cloud-download-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Allinea con Server</h2>
|
||||
<p class="settings-item-subtitle">Aggiorna canti e versione app</p>
|
||||
</ion-label>
|
||||
<ion-spinner slot="end" name="crescent" color="secondary" *ngIf="cantiService.loading()"></ion-spinner>
|
||||
</ion-item>
|
||||
<div class="sync-info ion-padding-bottom">
|
||||
<p class="outfit-font settings-item-subtitle">
|
||||
Versione: <strong>v{{ version }}</strong> •
|
||||
Canti: <strong>{{ cantiService.canti().length }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="sync-progress" *ngIf="cantiService.loading()">
|
||||
<div class="progress-bar" [style.width.%]="cantiService.progress()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda Pulsanti Canto -->
|
||||
<div class="settings-group glass ion-margin-top">
|
||||
@@ -234,12 +375,12 @@
|
||||
<div class="legend-list">
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="mic" color="danger"></ion-icon>
|
||||
<ion-icon name="videocam" color="success"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4>
|
||||
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
|
||||
<p class="outfit-font">
|
||||
Attiva lo scorrimento vocale intelligente. L'app ascolta il canto o lo strumento e fa scorrere testo e accordi a tempo di musica, senza bisogno di toccare lo schermo. Uno slider verticale permette di regolare la sensibilità.
|
||||
Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +421,7 @@
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Riavvia Canto</h4>
|
||||
<p class="outfit-font">
|
||||
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento vocale dello scroll acustico.
|
||||
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento dello scroll visuale.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,20 @@ import { ThemeService } from '../../services/theme.service';
|
||||
import { SettingsService } from '../../services/settings.service';
|
||||
import { CantiService } from '../../services/canti.service';
|
||||
import { ConnectivityService } from '../../services/connectivity.service';
|
||||
import { SwUpdate } from '@angular/service-worker';
|
||||
import { ToastController, ModalController, AlertController } from '@ionic/angular';
|
||||
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||
import { ToastController, ModalController, AlertController, LoadingController } from '@ionic/angular';
|
||||
import { VERSION } from '../../version';
|
||||
import { PlaylistService } from '../../services/playlist.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
|
||||
import { MyCantiService } from '../../services/my-canti.service';
|
||||
import { CantiLettureService } from '../../services/canti-letture.service';
|
||||
import { ComunitaService } from '../../services/comunita.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { showFullscreenUpdateOverlay } from '../../app.component';
|
||||
import { QrScannerComponent } from '../../components/qr-scanner/qr-scanner.component';
|
||||
import { IdentityQrModalComponent } from '../../components/identity-qr-modal/identity-qr-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -33,6 +37,7 @@ export class SettingsPage {
|
||||
private toastCtrl = inject(ToastController);
|
||||
private modalCtrl = inject(ModalController);
|
||||
private alertCtrl = inject(AlertController);
|
||||
private loadingCtrl = inject(LoadingController);
|
||||
private router = inject(Router);
|
||||
|
||||
public version = VERSION;
|
||||
@@ -50,62 +55,462 @@ export class SettingsPage {
|
||||
this.showIosInstructions = !this.showIosInstructions;
|
||||
}
|
||||
|
||||
async showBackupQrCode() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: IdentityQrModalComponent,
|
||||
componentProps: {
|
||||
userUuid: this.settingsService.userUuid()
|
||||
}
|
||||
});
|
||||
return await modal.present();
|
||||
}
|
||||
|
||||
async scanRestoreQrCode() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: QrScannerComponent
|
||||
});
|
||||
await modal.present();
|
||||
|
||||
const { data } = await modal.onWillDismiss();
|
||||
if (data) {
|
||||
this.confirmRestore(data);
|
||||
}
|
||||
}
|
||||
|
||||
async importPlaylistViaQr() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: QrScannerComponent
|
||||
});
|
||||
await modal.present();
|
||||
|
||||
const { data } = await modal.onWillDismiss();
|
||||
if (data) {
|
||||
// Naviga alla home passando il dato via state per il processing multi-scopo
|
||||
this.router.navigate(['/home'], { state: { scannedQrData: data } });
|
||||
}
|
||||
}
|
||||
|
||||
async manualRestore() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Ripristina con Codice',
|
||||
message: 'Digita o incolla il tuo codice identificativo univoco.',
|
||||
inputs: [
|
||||
{
|
||||
name: 'code',
|
||||
type: 'text',
|
||||
placeholder: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
},
|
||||
{
|
||||
text: 'Conferma',
|
||||
handler: (data) => {
|
||||
if (data.code && data.code.trim()) {
|
||||
this.confirmRestore(data.code.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
private async confirmRestore(code: string) {
|
||||
// Basic validation for UUID
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(code)) {
|
||||
const errorAlert = await this.alertCtrl.create({
|
||||
header: 'Codice Non Valido',
|
||||
message: 'Il codice inserito non sembra essere un identificativo univoco valido. Riprova.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await errorAlert.present();
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Ripristina Identità',
|
||||
message: 'Sei sicuro di voler ripristinare questa identità? L\'ID attuale del dispositivo verrà sovrascritto permanentemente ed eventuali canti e playlist remoti associati al nuovo ID verranno scaricati.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
},
|
||||
{
|
||||
text: 'Ripristina',
|
||||
role: 'destructive',
|
||||
handler: async () => {
|
||||
const loading = await this.loadingCtrl.create({
|
||||
message: 'Scaricamento dati da remoto...'
|
||||
});
|
||||
await loading.present();
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.canticristiani.it/${code}.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
const remoteJson = await response.json();
|
||||
if (Array.isArray(remoteJson)) {
|
||||
// Reconstruct user name/metadata
|
||||
const userMetadata = remoteJson.find((item: any) => item.momenti && item.momenti.includes('UserMetadata'));
|
||||
if (userMetadata && userMetadata.titolo) {
|
||||
this.settingsService.setUserName(userMetadata.titolo);
|
||||
}
|
||||
|
||||
// Reconstruct custom songs
|
||||
const customSongs = remoteJson
|
||||
.filter((item: any) => (!item.momenti || !item.momenti.includes('Playlist')) && (!item.momenti || !item.momenti.includes('UserMetadata')))
|
||||
.map((item: any) => ({
|
||||
id: `my_${item.id_canti}`,
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
testo: item.testo || '',
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
|
||||
durata: item.durata || '',
|
||||
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
|
||||
}));
|
||||
|
||||
if (customSongs.length > 0) {
|
||||
this.myCantiService.myCanti.set(customSongs);
|
||||
const storage = this.cantiService.getStorage();
|
||||
if (storage) {
|
||||
await storage.set('my-canti', customSongs);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct playlists
|
||||
const playlists = remoteJson
|
||||
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
|
||||
.map((item: any) => {
|
||||
let songSettings = {};
|
||||
if (item.periodi && item.periodi.length > 0) {
|
||||
try {
|
||||
songSettings = JSON.parse(item.periodi[0]);
|
||||
} catch (e) {}
|
||||
}
|
||||
return {
|
||||
id: String(item.id_canti),
|
||||
name: item.titolo,
|
||||
ids: item.testo ? item.testo.split(',') : [],
|
||||
songSettings: songSettings,
|
||||
createdAt: new Date()
|
||||
};
|
||||
});
|
||||
|
||||
if (playlists.length > 0) {
|
||||
this.playlistService.playlists.set(playlists);
|
||||
if (this.playlistService['_storage']) {
|
||||
const key = this.playlistService.getPlaylistsStorageKey();
|
||||
await this.playlistService['_storage'].set(key, playlists);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to restore backup data:', err);
|
||||
} finally {
|
||||
await loading.dismiss();
|
||||
}
|
||||
|
||||
this.settingsService.setUserUuid(code);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Identità e dati ripristinati con successo! Ricaricamento...',
|
||||
duration: 2000,
|
||||
color: 'success',
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.replace(window.location.origin + window.location.pathname);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
async restoreOriginalIdentity() {
|
||||
const original = this.settingsService.originalUserUuid();
|
||||
if (!original) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Errore',
|
||||
message: 'Nessun identificativo originario trovato.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await alert.present();
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Ripristina ID Originario',
|
||||
message: `Sei sicuro di voler ripristinare il codice ID originario assegnato alla prima installazione? L'ID attuale del dispositivo verrà sovrascritto e verranno scaricati eventuali canti e playlist associati all'ID originario.`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
},
|
||||
{
|
||||
text: 'Ripristina',
|
||||
handler: () => {
|
||||
this.confirmRestore(original);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
async syncLocalDataToServer() {
|
||||
const uid = this.settingsService.userUuid();
|
||||
if (!uid) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Errore',
|
||||
message: 'Nessun identificativo utente (UID) trovato.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await alert.present();
|
||||
return;
|
||||
}
|
||||
|
||||
const toastLoading = await this.toastCtrl.create({
|
||||
message: 'Invio backup in corso...',
|
||||
duration: 1500,
|
||||
color: 'secondary'
|
||||
});
|
||||
await toastLoading.present();
|
||||
|
||||
try {
|
||||
await this.playlistService.syncLocalDataToServer();
|
||||
await toastLoading.dismiss();
|
||||
|
||||
const toastSuccess = await this.toastCtrl.create({
|
||||
message: 'Backup sincronizzato con successo!',
|
||||
duration: 3000,
|
||||
color: 'success'
|
||||
});
|
||||
await toastSuccess.present();
|
||||
} catch (err: any) {
|
||||
try {
|
||||
await toastLoading.dismiss();
|
||||
} catch (e) {}
|
||||
console.error('Failed to backup to server:', err);
|
||||
const alertError = await this.alertCtrl.create({
|
||||
header: 'Errore di sincronizzazione',
|
||||
message: 'Impossibile inviare il backup al server. Controlla la connessione e riprova.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await alertError.present();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackupFromServer() {
|
||||
const code = this.settingsService.userUuid();
|
||||
if (!code) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Errore',
|
||||
message: 'Nessun identificativo utente (UID) trovato.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await alert.present();
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Ripristina Backup',
|
||||
message: 'Sei sicuro di voler ripristinare i dati dal server? I tuoi canti personali e le tue scalette locali verranno allineati con l\'ultimo backup presente sul server.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
},
|
||||
{
|
||||
text: 'Ripristina',
|
||||
handler: async () => {
|
||||
const loading = await this.loadingCtrl.create({
|
||||
message: 'Scaricamento dati da remoto...'
|
||||
});
|
||||
await loading.present();
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.canticristiani.it/${code}.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
const remoteJson = await response.json();
|
||||
if (Array.isArray(remoteJson)) {
|
||||
// Reconstruct user name/metadata
|
||||
const userMetadata = remoteJson.find((item: any) => item.momenti && item.momenti.includes('UserMetadata'));
|
||||
if (userMetadata && userMetadata.titolo) {
|
||||
this.settingsService.setUserName(userMetadata.titolo);
|
||||
}
|
||||
|
||||
// Reconstruct custom songs
|
||||
const customSongs = remoteJson
|
||||
.filter((item: any) => (!item.momenti || !item.momenti.includes('Playlist')) && (!item.momenti || !item.momenti.includes('UserMetadata')))
|
||||
.map((item: any) => ({
|
||||
id: `my_${item.id_canti}`,
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
testo: item.testo || '',
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
|
||||
durata: item.durata || '',
|
||||
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
|
||||
}));
|
||||
|
||||
this.myCantiService.myCanti.set(customSongs);
|
||||
const storage = this.cantiService.getStorage();
|
||||
if (storage) {
|
||||
await storage.set('my-canti', customSongs);
|
||||
}
|
||||
|
||||
// Reconstruct playlists
|
||||
const playlists = remoteJson
|
||||
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
|
||||
.map((item: any) => {
|
||||
let songSettings = {};
|
||||
if (item.periodi && item.periodi.length > 0) {
|
||||
try {
|
||||
songSettings = JSON.parse(item.periodi[0]);
|
||||
} catch (e) {}
|
||||
}
|
||||
return {
|
||||
id: String(item.id_canti),
|
||||
name: item.titolo,
|
||||
ids: item.testo ? item.testo.split(',') : [],
|
||||
songSettings: songSettings,
|
||||
createdAt: new Date()
|
||||
};
|
||||
});
|
||||
|
||||
this.playlistService.playlists.set(playlists);
|
||||
if (this.playlistService['_storage']) {
|
||||
const key = this.playlistService.getPlaylistsStorageKey();
|
||||
await this.playlistService['_storage'].set(key, playlists);
|
||||
}
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Dati ripristinati con successo! Ricaricamento...',
|
||||
duration: 2000,
|
||||
color: 'success',
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.replace(window.location.origin + window.location.pathname);
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error('Formato dati del backup non valido.');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Nessun backup trovato sul server per questo ID.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to restore backup data:', err);
|
||||
const errorAlert = await this.alertCtrl.create({
|
||||
header: 'Errore Ripristino',
|
||||
message: err.message || 'Impossibile scaricare il backup dal server. Verifica la connessione.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await errorAlert.present();
|
||||
} finally {
|
||||
await loading.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
onModeChange(event: any) {
|
||||
this.settingsService.setShowChordsDefault(event.detail.value === 'chords');
|
||||
}
|
||||
|
||||
onNameChange(event: any) {
|
||||
this.settingsService.setUserName(event.target.value);
|
||||
}
|
||||
|
||||
onMassChange(event: any) {
|
||||
this.cantiLettureService.setSelectedMass(event.detail.value);
|
||||
}
|
||||
|
||||
async fullRefresh() {
|
||||
// 1. Refresh JSON data
|
||||
this.cantiService.refresh();
|
||||
|
||||
// 2. Refresh liturgical readings JSON
|
||||
try {
|
||||
await this.cantiLettureService.fetchData();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh liturgical readings:', err);
|
||||
}
|
||||
|
||||
// 3. Refresh community data if a code is active
|
||||
const comunitaCode = this.comunitaService.comunitaCode();
|
||||
if (comunitaCode) {
|
||||
try {
|
||||
await this.comunitaService.setComunitaCode(comunitaCode);
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh community data:', err);
|
||||
async onComunitaToggleChange(event: any) {
|
||||
const checked = event.detail.checked;
|
||||
if (checked) {
|
||||
this.settingsService.comunitaEnabled.set(true);
|
||||
localStorage.setItem('comunita-enabled', 'true');
|
||||
} else {
|
||||
this.settingsService.comunitaEnabled.set(false);
|
||||
localStorage.setItem('comunita-enabled', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check for Service Worker updates
|
||||
if (this.swUpdate.isEnabled) {
|
||||
try {
|
||||
const updateFound = await this.swUpdate.checkForUpdate();
|
||||
if (updateFound) {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Nuova versione disponibile! Aggiornamento in corso...',
|
||||
duration: 2000,
|
||||
color: 'secondary'
|
||||
async editComunita() {
|
||||
await this.comunitaService.setComunitaCode('');
|
||||
}
|
||||
|
||||
async saveComunitaCode(code: string) {
|
||||
const trimmed = (code || '').trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const loading = await this.loadingCtrl.create({
|
||||
message: 'Caricamento 0%',
|
||||
cssClass: 'premium-loading',
|
||||
spinner: 'crescent'
|
||||
});
|
||||
await toast.present();
|
||||
await loading.present();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for updates', err);
|
||||
}
|
||||
let progressInterval = setInterval(() => {
|
||||
const pct = this.comunitaService.loadingProgress();
|
||||
loading.message = `Caricamento ${pct}%`;
|
||||
if (pct >= 100) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const success = await this.comunitaService.setComunitaCode(trimmed);
|
||||
clearInterval(progressInterval);
|
||||
await loading.dismiss();
|
||||
|
||||
if (success) {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Dati aggiornati correttamente!',
|
||||
message: `Comunità attivata: ${this.comunitaService.comunitaNome()}`,
|
||||
duration: 2000,
|
||||
color: 'success'
|
||||
});
|
||||
await toast.present();
|
||||
} else {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Codice non trovato o errore di connessione.',
|
||||
duration: 2000,
|
||||
color: 'danger'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
}
|
||||
|
||||
async removeComunita() {
|
||||
await this.comunitaService.setComunitaCode('');
|
||||
this.settingsService.comunitaEnabled.set(false);
|
||||
localStorage.setItem('comunita-enabled', 'false');
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Comunità disattivata.',
|
||||
duration: 2000,
|
||||
color: 'secondary'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,22 @@ export class AudioEngineService {
|
||||
public searchTranscript = signal<string>('');
|
||||
public isSearching = signal<boolean>(false);
|
||||
public sensitivity = signal<number>(75); // Default sensitivity (50-100)
|
||||
public speechRecognitionActive = signal<boolean>(false);
|
||||
|
||||
// Voice/Guitar detection signals
|
||||
public voiceDetected = signal<boolean>(false);
|
||||
public guitarDetected = signal<boolean>(false);
|
||||
|
||||
/** Soglia del punteggio voce: valori più bassi = più sensibile alla voce (range 0.05 – 0.50) */
|
||||
public voiceThreshold = signal<number>(0.15);
|
||||
|
||||
/** Debug: punteggio voce corrente (0-1) per feedback visivo */
|
||||
public voiceScore = signal<number>(0);
|
||||
|
||||
// Real-time background continuous transcript and word buffer for reliability checks
|
||||
public backgroundTranscript = signal<string>('');
|
||||
private backgroundRecognition: any = null;
|
||||
private recentWordsBuffer: { word: string, timestamp: number }[] = [];
|
||||
|
||||
public clearSearchTranscript() {
|
||||
this.searchTranscript.set('');
|
||||
@@ -26,6 +42,7 @@ export class AudioEngineService {
|
||||
private isSpeaking: boolean = false;
|
||||
private lastSilenceTime: number = Date.now();
|
||||
private lastWordTime: number = 0;
|
||||
private speakingStartTime: number = 0;
|
||||
|
||||
// Constants for tuning - Optimized for close proximity (singer/guitarist)
|
||||
private readonly SILENCE_GAP = 100; // ms
|
||||
@@ -33,11 +50,66 @@ export class AudioEngineService {
|
||||
private readonly COOLDOWN = 1000; // ms
|
||||
private peakEnergy: number = 0;
|
||||
private lineStarted: boolean = false;
|
||||
private noiseFloor: number = 30; // Adaptive noise floor starting point
|
||||
|
||||
// --- Advanced Voice Detection State ---
|
||||
|
||||
// Smoothing buffer (16 frames ~260ms a 60fps)
|
||||
private readonly SMOOTHING_FRAMES = 16;
|
||||
private voiceScoreBuffer: number[] = [];
|
||||
|
||||
// Spectral flux: previous frame spectrum for change detection
|
||||
private previousSpectrum: Float32Array | null = null;
|
||||
|
||||
// Hysteresis: once voice is detected, it stays on for at least this many frames
|
||||
private readonly VOICE_HOLD_FRAMES = 12; // ~200ms
|
||||
private voiceHoldCounter: number = 0;
|
||||
|
||||
// Sub-band definitions (Hz) - 8 fine-grained bands
|
||||
private readonly BANDS = [
|
||||
{ name: 'sub_bass', start: 80, end: 150 }, // Fondamentali basse chitarra (Mi2=82, La2=110)
|
||||
{ name: 'bass', start: 150, end: 300 }, // Fondamentali voce maschile/femminile + chitarra
|
||||
{ name: 'low_mid', start: 300, end: 600 }, // Armoniche chitarra dominanti
|
||||
{ name: 'mid', start: 600, end: 1000 }, // Zona transizione
|
||||
{ name: 'formant_f1', start: 1000, end: 1500 }, // Formante F1 voce (vocali aperte)
|
||||
{ name: 'formant_f2', start: 1500, end: 2500 }, // Formante F2 voce (KEY: quasi assente in chitarra)
|
||||
{ name: 'formant_f3', start: 2500, end: 3500 }, // Formante F3 voce (sibilanti morbide)
|
||||
{ name: 'sibilants', start: 3500, end: 6000 }, // Consonanti sibilanti (s, t, f, sh) - SOLO voce
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.initSpeechRecognition();
|
||||
}
|
||||
|
||||
private initBackgroundRecognition() {
|
||||
// Disattivato per utilizzare esclusivamente l'analizzatore FFT acustico locale
|
||||
}
|
||||
|
||||
private startBackgroundRecognition() {
|
||||
// Disattivato
|
||||
}
|
||||
|
||||
private stopBackgroundRecognition() {
|
||||
// Disattivato
|
||||
}
|
||||
|
||||
private updateRecentWords(transcript: string) {
|
||||
// Disattivato
|
||||
}
|
||||
|
||||
public getRecentWords(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public getRecentWordsSince(timestamp: number): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public clearRecentWords() {
|
||||
this.recentWordsBuffer = [];
|
||||
this.backgroundTranscript.set('');
|
||||
}
|
||||
|
||||
private initSpeechRecognition() {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (SpeechRecognition) {
|
||||
@@ -84,93 +156,446 @@ export class AudioEngineService {
|
||||
async startListening() {
|
||||
if (this.isListening()) return;
|
||||
|
||||
this.isListening.set(true);
|
||||
this.voiceDetected.set(false);
|
||||
this.guitarDetected.set(false);
|
||||
this.voiceScoreBuffer = [];
|
||||
this.previousSpectrum = null;
|
||||
this.voiceHoldCounter = 0;
|
||||
this.clearRecentWords();
|
||||
|
||||
console.log('[AudioEngine] Starting local FFT Audio Analyser');
|
||||
await this.startOfflineAnalyser();
|
||||
}
|
||||
|
||||
public async startOfflineAnalyser() {
|
||||
if (this.analyser) return; // Già attivo
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false // Prevents boosting background noise during silence
|
||||
autoGainControl: false // Impedisce il boost automatico del rumore di fondo nel silenzio
|
||||
}
|
||||
});
|
||||
this.audioContext = new AudioContext();
|
||||
const source = this.audioContext.createMediaStreamSource(this.stream);
|
||||
|
||||
// FFT a 4096 punti per risoluzione ~10.7 Hz/bin (a 44100 Hz)
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 512;
|
||||
this.analyser.fftSize = 4096;
|
||||
this.analyser.smoothingTimeConstant = 0.4; // Smoothing moderato per stabilità spettrale
|
||||
|
||||
source.connect(this.analyser);
|
||||
this.isListening.set(true);
|
||||
this.processAudio();
|
||||
} catch (err) {
|
||||
console.error('Error accessing microphone', err);
|
||||
console.error('[AudioEngine] Error accessing microphone for offline analyser:', err);
|
||||
// Se fallisce anche il mic locale, proviamo a ripristinare lo stato
|
||||
this.stopListening();
|
||||
alert('Errore microfono: assicurati di usare HTTPS e di aver dato i permessi.');
|
||||
}
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
||||
|
||||
// Rilascia le risorse del microfono locale
|
||||
this.stream?.getTracks().forEach(track => track.stop());
|
||||
this.audioContext?.close();
|
||||
this.stream = null;
|
||||
this.audioContext = null;
|
||||
this.analyser = null;
|
||||
|
||||
this.isListening.set(false);
|
||||
this.energyLevel.set(0);
|
||||
this.voiceDetected.set(false);
|
||||
this.guitarDetected.set(false);
|
||||
this.voiceScore.set(0);
|
||||
this.voiceScoreBuffer = [];
|
||||
this.previousSpectrum = null;
|
||||
this.voiceHoldCounter = 0;
|
||||
this.stopBackgroundRecognition();
|
||||
this.clearRecentWords();
|
||||
this.speechRecognitionActive.set(false);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ANALISI SPETTRALE AVANZATA MULTI-FEATURE
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Calcola l'energia media in una sotto-banda dello spettro.
|
||||
*/
|
||||
private getBandEnergy(dataArray: Uint8Array, binWidth: number, startHz: number, endHz: number): number {
|
||||
const startBin = Math.max(0, Math.floor(startHz / binWidth));
|
||||
const endBin = Math.min(dataArray.length - 1, Math.floor(endHz / binWidth));
|
||||
if (endBin <= startBin) return 0;
|
||||
let sum = 0;
|
||||
for (let i = startBin; i <= endBin; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
return sum / (endBin - startBin + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE 1: Rapporto formanti vocali F2+F3 vs bande strumentali (low+mid)
|
||||
*
|
||||
* La voce umana ha formanti F2 (1500-2500 Hz) e F3 (2500-3500 Hz) molto prominenti.
|
||||
* La chitarra acustica ha pochissima energia in queste bande.
|
||||
* Rapporto alto = voce, basso = strumento.
|
||||
*/
|
||||
private calcFormantRatio(bandEnergies: number[]): number {
|
||||
const instrumentEnergy = bandEnergies[0] + bandEnergies[1] + bandEnergies[2] + bandEnergies[3]; // 80-1000 Hz
|
||||
const formantEnergy = bandEnergies[5] + bandEnergies[6]; // F2 (1500-2500) + F3 (2500-3500)
|
||||
return formantEnergy / (instrumentEnergy + 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE 2: Energia sibilanti (3500-6000 Hz)
|
||||
*
|
||||
* Solo la voce umana produce consonanti sibilanti come "s", "t", "f", "sh", "z"
|
||||
* che hanno energia significativa sopra 3500 Hz. Gli strumenti acustici (chitarra,
|
||||
* pianoforte, etc.) hanno energia trascurabile in questa banda.
|
||||
*
|
||||
* Ritorna un valore normalizzato 0-1.
|
||||
*/
|
||||
private calcSibilantScore(bandEnergies: number[], totalEnergy: number): number {
|
||||
if (totalEnergy < 1) return 0;
|
||||
const sibilantEnergy = bandEnergies[7]; // 3500-6000 Hz
|
||||
// Normalizza: una sibilante tipica ha 15-40% dell'energia totale
|
||||
return Math.min(1, sibilantEnergy / (totalEnergy * 0.15 + 0.001));
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE 3: Spectral Flatness (entropia di Wiener)
|
||||
*
|
||||
* Misura quanto lo spettro è "piatto" (rumoroso) vs "piccato" (tonale).
|
||||
* - Chitarra: spettro molto tonale con picchi armonici netti → flatness BASSA
|
||||
* - Voce (consonanti): spettro più rumoroso → flatness ALTA
|
||||
* - Voce (vocali): moderatamente tonale ma con formanti larghe → flatness MEDIA
|
||||
*
|
||||
* Formula: media_geometrica / media_aritmetica (0=tono puro, 1=rumore bianco)
|
||||
*/
|
||||
private calcSpectralFlatness(dataArray: Uint8Array, binWidth: number): number {
|
||||
// Calcola nella regione 300-6000 Hz (dove la discriminazione è più utile)
|
||||
const startBin = Math.max(1, Math.floor(300 / binWidth));
|
||||
const endBin = Math.min(dataArray.length - 1, Math.floor(6000 / binWidth));
|
||||
const n = endBin - startBin + 1;
|
||||
if (n <= 0) return 0;
|
||||
|
||||
let logSum = 0;
|
||||
let arithmeticSum = 0;
|
||||
let zeroCount = 0;
|
||||
|
||||
for (let i = startBin; i <= endBin; i++) {
|
||||
const val = Math.max(dataArray[i], 0.001); // Avoid log(0)
|
||||
logSum += Math.log(val);
|
||||
arithmeticSum += val;
|
||||
if (dataArray[i] === 0) zeroCount++;
|
||||
}
|
||||
|
||||
if (zeroCount > n * 0.5) return 0; // Troppi zeri = silenzio
|
||||
|
||||
const geometricMean = Math.exp(logSum / n);
|
||||
const arithmeticMean = arithmeticSum / n;
|
||||
|
||||
if (arithmeticMean < 0.001) return 0;
|
||||
return Math.min(1, geometricMean / arithmeticMean);
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE 4: Spectral Flux (velocità di cambiamento spettrale)
|
||||
*
|
||||
* Misura quanto rapidamente cambia la forma dello spettro tra frame successivi.
|
||||
* - Voce: alta flux (alternanza vocali/consonanti, prosodia)
|
||||
* - Chitarra: bassa flux (note sostenute, spettro stabile)
|
||||
*
|
||||
* Ritorna un valore normalizzato 0-1.
|
||||
*/
|
||||
private calcSpectralFlux(dataArray: Uint8Array, binWidth: number): number {
|
||||
const startBin = Math.max(0, Math.floor(200 / binWidth));
|
||||
const endBin = Math.min(dataArray.length - 1, Math.floor(5000 / binWidth));
|
||||
|
||||
if (!this.previousSpectrum) {
|
||||
this.previousSpectrum = new Float32Array(dataArray.length);
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
this.previousSpectrum[i] = dataArray[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
let flux = 0;
|
||||
let maxFlux = 0;
|
||||
for (let i = startBin; i <= endBin; i++) {
|
||||
const diff = dataArray[i] - this.previousSpectrum[i];
|
||||
// Solo variazioni positive (onset) per evitare il decadimento naturale
|
||||
if (diff > 0) {
|
||||
flux += diff * diff;
|
||||
}
|
||||
maxFlux += 255 * 255; // Massimo teorico
|
||||
}
|
||||
|
||||
// Aggiorna il buffer dello spettro precedente
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
this.previousSpectrum[i] = dataArray[i];
|
||||
}
|
||||
|
||||
if (maxFlux === 0) return 0;
|
||||
// Normalizza e scala logaritmicamente per sensibilità
|
||||
const normalizedFlux = flux / maxFlux;
|
||||
return Math.min(1, Math.sqrt(normalizedFlux) * 10); // Amplifica le piccole variazioni
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE 5: Formant Peak Detection
|
||||
*
|
||||
* Cerca picchi spettrali caratteristici nella regione delle formanti vocali (800-3500 Hz).
|
||||
* La voce umana produce 2-4 picchi prominenti (formanti F1-F4).
|
||||
* La chitarra ha uno spettro armonico regolare senza picchi formantici.
|
||||
*
|
||||
* Ritorna il numero di picchi formantici rilevati (0-4), normalizzato 0-1.
|
||||
*/
|
||||
private calcFormantPeaks(dataArray: Uint8Array, binWidth: number): number {
|
||||
const startBin = Math.max(0, Math.floor(800 / binWidth));
|
||||
const endBin = Math.min(dataArray.length - 1, Math.floor(3500 / binWidth));
|
||||
|
||||
// Calcola la media locale per determinare la soglia di prominenza
|
||||
let sum = 0;
|
||||
for (let i = startBin; i <= endBin; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
const avgEnergy = sum / Math.max(1, endBin - startBin + 1);
|
||||
|
||||
if (avgEnergy < 5) return 0; // Silenzio
|
||||
|
||||
// Cerca picchi: un bin è un picco se è maggiore dei 5 bin a sinistra e 5 a destra
|
||||
// e supera la media di almeno 30%
|
||||
const peakThreshold = avgEnergy * 1.3;
|
||||
const windowSize = Math.max(3, Math.floor(100 / binWidth)); // ~100 Hz di finestra
|
||||
let peakCount = 0;
|
||||
let lastPeakBin = -windowSize * 2; // Evita di contare picchi troppo vicini
|
||||
|
||||
for (let i = startBin + windowSize; i <= endBin - windowSize; i++) {
|
||||
if (dataArray[i] < peakThreshold) continue;
|
||||
|
||||
let isPeak = true;
|
||||
for (let j = 1; j <= windowSize; j++) {
|
||||
if (dataArray[i] < dataArray[i - j] || dataArray[i] < dataArray[i + j]) {
|
||||
isPeak = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPeak && (i - lastPeakBin) > windowSize) {
|
||||
peakCount++;
|
||||
lastPeakBin = i;
|
||||
if (peakCount >= 4) break; // Max 4 formanti
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(1, peakCount / 3); // 3 formanti = punteggio pieno
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE 6: Rapporto energia alta/bassa (spectral tilt)
|
||||
*
|
||||
* La voce umana (soprattutto da vicino al microfono) ha uno spettro più "piatto"
|
||||
* con energia significativa anche nelle alte frequenze.
|
||||
* La chitarra ha una forte caduta sopra 1-2 kHz.
|
||||
*/
|
||||
private calcSpectralTilt(bandEnergies: number[]): number {
|
||||
const lowEnergy = bandEnergies[0] + bandEnergies[1] + bandEnergies[2]; // 80-600 Hz
|
||||
const highEnergy = bandEnergies[5] + bandEnergies[6] + bandEnergies[7]; // 1500-6000 Hz
|
||||
if (lowEnergy < 0.001) return 0;
|
||||
// Un rapporto alto indica più energia nelle alte frequenze (voce)
|
||||
return Math.min(1, highEnergy / (lowEnergy + 0.001));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcola il punteggio voce combinato da tutte le feature.
|
||||
*
|
||||
* Pesi delle feature (calibrati per massimizzare la discriminazione):
|
||||
* - Formant ratio: 25% (molto discriminante)
|
||||
* - Sibilant score: 20% (quasi esclusivo della voce)
|
||||
* - Spectral flatness: 15% (voce più rumorosa di chitarra)
|
||||
* - Spectral flux: 15% (voce cambia più rapidamente)
|
||||
* - Formant peaks: 15% (struttura formanti unica della voce)
|
||||
* - Spectral tilt: 10% (distribuzione energia)
|
||||
*/
|
||||
private calcVoiceScore(
|
||||
dataArray: Uint8Array,
|
||||
bandEnergies: number[],
|
||||
totalEnergy: number,
|
||||
binWidth: number
|
||||
): number {
|
||||
const formantRatio = this.calcFormantRatio(bandEnergies);
|
||||
const sibilantScore = this.calcSibilantScore(bandEnergies, totalEnergy);
|
||||
const flatness = this.calcSpectralFlatness(dataArray, binWidth);
|
||||
const flux = this.calcSpectralFlux(dataArray, binWidth);
|
||||
const formantPeaks = this.calcFormantPeaks(dataArray, binWidth);
|
||||
const spectralTilt = this.calcSpectralTilt(bandEnergies);
|
||||
|
||||
// Normalizza formantRatio: tipicamente 0.05-0.5 per voce, 0-0.05 per chitarra
|
||||
const normalizedFormantRatio = Math.min(1, formantRatio / 0.4);
|
||||
|
||||
// Punteggio pesato
|
||||
const score =
|
||||
normalizedFormantRatio * 0.25 +
|
||||
sibilantScore * 0.20 +
|
||||
flatness * 0.15 +
|
||||
flux * 0.15 +
|
||||
formantPeaks * 0.15 +
|
||||
spectralTilt * 0.10;
|
||||
|
||||
return Math.min(1, Math.max(0, score));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// MAIN AUDIO PROCESSING LOOP
|
||||
// =====================================================================
|
||||
|
||||
private processAudio() {
|
||||
if (!this.analyser) return;
|
||||
|
||||
const bufferLength = this.analyser.frequencyBinCount;
|
||||
const bufferLength = this.analyser.frequencyBinCount; // 2048 con fftSize=4096
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
const sampleRate = this.audioContext ? this.audioContext.sampleRate : 44100;
|
||||
const binWidth = sampleRate / 4096; // ~10.7 Hz/bin
|
||||
|
||||
// Energia minima per considerare che c'è suono (evita falsi positivi nel silenzio)
|
||||
const MIN_ENERGY = 5;
|
||||
|
||||
const analyze = () => {
|
||||
this.analyser!.getByteFrequencyData(dataArray);
|
||||
if (!this.analyser) return;
|
||||
this.analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate average energy (volume)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
const avgEnergy = sum / bufferLength;
|
||||
this.energyLevel.set(avgEnergy);
|
||||
// Calcola energia in ciascuna delle 8 sotto-bande
|
||||
const bandEnergies: number[] = this.BANDS.map(band =>
|
||||
this.getBandEnergy(dataArray, binWidth, band.start, band.end)
|
||||
);
|
||||
|
||||
// Energia totale media
|
||||
const totalEnergy = bandEnergies.reduce((a, b) => a + b, 0) / bandEnergies.length;
|
||||
|
||||
// Feedback visuale (barra energia) — usa la media delle bande vocali per coerenza
|
||||
const vocalDisplayEnergy = (bandEnergies[4] + bandEnergies[5] + bandEnergies[6]) / 3;
|
||||
this.energyLevel.set(vocalDisplayEnergy);
|
||||
|
||||
const now = Date.now();
|
||||
// Balanced mapping: 0% -> 220 (very quiet), 100% -> 20 (very sensitive)
|
||||
const currentThreshold = 220 - (this.sensitivity() * 2.0);
|
||||
const hasSound = totalEnergy > MIN_ENERGY;
|
||||
|
||||
if (avgEnergy > currentThreshold) {
|
||||
if (avgEnergy > this.peakEnergy) {
|
||||
this.peakEnergy = avgEnergy;
|
||||
if (hasSound) {
|
||||
// Calcola il punteggio voce multi-feature
|
||||
const rawScore = this.calcVoiceScore(dataArray, bandEnergies, totalEnergy, binWidth);
|
||||
|
||||
// Aggiungi al buffer di smoothing
|
||||
this.voiceScoreBuffer.push(rawScore);
|
||||
if (this.voiceScoreBuffer.length > this.SMOOTHING_FRAMES) {
|
||||
this.voiceScoreBuffer.shift();
|
||||
}
|
||||
|
||||
// Media pesata: i frame più recenti contano di più
|
||||
let weightedSum = 0;
|
||||
let weightTotal = 0;
|
||||
for (let i = 0; i < this.voiceScoreBuffer.length; i++) {
|
||||
const weight = (i + 1); // Peso crescente
|
||||
weightedSum += this.voiceScoreBuffer[i] * weight;
|
||||
weightTotal += weight;
|
||||
}
|
||||
const smoothedScore = weightedSum / weightTotal;
|
||||
|
||||
this.voiceScore.set(smoothedScore);
|
||||
|
||||
// Determinazione con isteresi
|
||||
const threshold = this.voiceThreshold();
|
||||
|
||||
if (smoothedScore > threshold) {
|
||||
// Voce rilevata
|
||||
this.voiceDetected.set(true);
|
||||
this.guitarDetected.set(false);
|
||||
this.voiceHoldCounter = this.VOICE_HOLD_FRAMES;
|
||||
} else if (this.voiceHoldCounter > 0) {
|
||||
// Isteresi: mantieni lo stato "voce" per evitare oscillazioni
|
||||
this.voiceHoldCounter--;
|
||||
// Lo stato resta quello precedente (voce)
|
||||
} else {
|
||||
// Strumento/rumore ambientale
|
||||
this.voiceDetected.set(false);
|
||||
this.guitarDetected.set(true);
|
||||
}
|
||||
} else {
|
||||
// Silenzio
|
||||
this.voiceDetected.set(false);
|
||||
this.guitarDetected.set(false);
|
||||
this.voiceScore.set(0);
|
||||
this.voiceHoldCounter = 0;
|
||||
}
|
||||
|
||||
// --- Logica conteggio parole (solo se voce rilevata) ---
|
||||
|
||||
// Tracciamento adattivo del rumore di fondo
|
||||
if (totalEnergy < this.noiseFloor) {
|
||||
this.noiseFloor = this.noiseFloor * 0.95 + totalEnergy * 0.05;
|
||||
} else {
|
||||
this.noiseFloor = this.noiseFloor * 0.998 + totalEnergy * 0.002;
|
||||
}
|
||||
|
||||
// Soglia di sensibilità definita dall'utente
|
||||
const userThreshold = 220 - (this.sensitivity() * 2.0);
|
||||
const currentThreshold = Math.max(userThreshold, this.noiseFloor + 12);
|
||||
|
||||
// *** CONTEGGIO PAROLE SOLO QUANDO LA VOCE UMANA È DOMINANTE ***
|
||||
const isVoice = this.voiceDetected();
|
||||
|
||||
if (isVoice && totalEnergy > currentThreshold) {
|
||||
if (totalEnergy > this.peakEnergy) {
|
||||
this.peakEnergy = totalEnergy;
|
||||
}
|
||||
|
||||
if (!this.isSpeaking) {
|
||||
this.isSpeaking = true;
|
||||
this.peakEnergy = avgEnergy;
|
||||
this.speakingStartTime = now;
|
||||
this.peakEnergy = totalEnergy;
|
||||
this.lastSilenceTime = now;
|
||||
}
|
||||
|
||||
// Se siamo in "speaking" e sentiamo un calo significativo rispetto al picco recente (almeno 30% di calo)
|
||||
// Questo permette di avanzare anche se c'è rumore di fondo sopra la soglia base.
|
||||
const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy;
|
||||
if (this.isSpeaking && dropRatio > 0.35 && this.peakEnergy > currentThreshold * 1.2) {
|
||||
if (now - this.lastWordTime > this.COOLDOWN) {
|
||||
// Rilevamento della caduta di energia relativa per stacchi sillabici
|
||||
const dropRatio = (this.peakEnergy - totalEnergy) / this.peakEnergy;
|
||||
if (this.isSpeaking && dropRatio > 0.20 && this.peakEnergy > currentThreshold * 1.1) {
|
||||
const speakDuration = now - this.speakingStartTime;
|
||||
if (now - this.lastWordTime > this.COOLDOWN && speakDuration > 120) {
|
||||
this.linesDetected.update(v => v + 1);
|
||||
this.lastWordTime = now;
|
||||
this.peakEnergy = avgEnergy; // Reset peak
|
||||
this.peakEnergy = totalEnergy;
|
||||
this.isSpeaking = false;
|
||||
console.log('Line advanced - relative drop detected', { dropRatio, avgEnergy, peak: this.peakEnergy });
|
||||
console.log('[VoiceDetect] Word counted - voice energy drop', {
|
||||
score: this.voiceScore().toFixed(3),
|
||||
dropRatio: dropRatio.toFixed(2),
|
||||
duration: speakDuration
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSilenceTime = now;
|
||||
} else {
|
||||
// Sotto soglia (silenzio per il sistema)
|
||||
if (this.isSpeaking && (now - this.lastSilenceTime > 200)) {
|
||||
if (now - this.lastWordTime > this.COOLDOWN) {
|
||||
// Sotto soglia, chitarra, o silenzio
|
||||
if (this.isSpeaking && (now - this.lastSilenceTime > 180)) {
|
||||
// Calcola la durata reale del segnale vocale escludendo la finestra di silenzio (180ms)
|
||||
const actualSoundDuration = this.lastSilenceTime - this.speakingStartTime;
|
||||
if (isVoice && now - this.lastWordTime > this.COOLDOWN && actualSoundDuration > 100) {
|
||||
this.linesDetected.update(v => v + 1);
|
||||
this.lastWordTime = now;
|
||||
console.log('Line advanced - silence detected');
|
||||
console.log('[VoiceDetect] Word counted - voice silence gap', { duration: actualSoundDuration });
|
||||
}
|
||||
this.isSpeaking = false;
|
||||
this.peakEnergy = 0;
|
||||
}
|
||||
|
||||
// Se la chitarra è dominante, resetta lo stato di parlato
|
||||
if (!isVoice && this.isSpeaking) {
|
||||
this.isSpeaking = false;
|
||||
this.peakEnergy = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.animationFrame = requestAnimationFrame(analyze);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, signal, inject, effect } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface Suggestion {
|
||||
id_canto?: number;
|
||||
@@ -45,7 +46,7 @@ export class CantiLettureService {
|
||||
public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso
|
||||
public suggestionsMomentsMap = signal<Map<number, string[]>>(new Map()); // id_canto -> moments[]
|
||||
|
||||
private JSON_URL = 'http://185.193.67.105:3000/cantiletture.json';
|
||||
private JSON_URL = 'https://api.canticristiani.it/cantiletture.json';
|
||||
private SECURE_JSON_URL = '/api/cantiletture.json';
|
||||
|
||||
constructor() {
|
||||
@@ -127,8 +128,19 @@ export class CantiLettureService {
|
||||
|
||||
const savedDate = localStorage.getItem('selected-mass-date');
|
||||
if (savedDate) {
|
||||
const masses = this.availableMasses();
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
// Only restore savedDate if it exists in available masses and is not outdated
|
||||
const isValidAndNotPast = masses.some(m => m.date === savedDate && m.date >= todayStr);
|
||||
if (isValidAndNotPast) {
|
||||
this.selectedMassDate.set(savedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
await this.fetchData();
|
||||
@@ -137,24 +149,45 @@ export class CantiLettureService {
|
||||
async fetchData() {
|
||||
try {
|
||||
let fetchedData: CantiLettureData | null = null;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||
const primaryUrl = isProduction
|
||||
? `${window.location.origin}${this.SECURE_JSON_URL}`
|
||||
: 'https://www.canticristiani.it/api/cantiletture.json';
|
||||
const fallbackUrl = isProduction
|
||||
? 'https://www.canticristiani.it/api/cantiletture.json'
|
||||
: this.JSON_URL;
|
||||
|
||||
// 1. Try static JSON endpoint first
|
||||
try {
|
||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
if (res && res.masses && res.week_end && res.week_end >= todayStr) {
|
||||
fetchedData = res;
|
||||
} else {
|
||||
console.warn('Primary JSON data is missing or out of date:', res?.week_end);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err);
|
||||
console.warn('Primary fetch failed:', err);
|
||||
}
|
||||
|
||||
// 2. Fallback to direct API endpoint using Basic Auth credentials
|
||||
if (!fetchedData) {
|
||||
try {
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`));
|
||||
} catch (fallbackErr) {
|
||||
console.error('Fallback fetch also failed:', fallbackErr);
|
||||
console.log('Fetching mass data from direct API:', this.JSON_URL);
|
||||
const authUser = environment.apiAuthUser || 'canti';
|
||||
const authPass = environment.apiAuthPass || 'antani2026';
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': 'Basic ' + btoa(`${authUser}:${authPass}`)
|
||||
});
|
||||
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${this.JSON_URL}?t=${Date.now()}`, { headers }));
|
||||
if (res && res.masses) {
|
||||
fetchedData = res;
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.error('Direct API fetch failed:', authErr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +227,17 @@ export class CantiLettureService {
|
||||
|
||||
this.availableMasses.set(massesList);
|
||||
|
||||
// Always select today's mass if available on load, else find closest future date
|
||||
if (massesList.length > 0) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
const currentSelected = this.selectedMassDate();
|
||||
const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr);
|
||||
|
||||
if (!isCurrentValid) {
|
||||
const match = massesList.find(m => m.date === todayStr);
|
||||
if (match) {
|
||||
this.selectedMassDate.set(match.date);
|
||||
@@ -216,6 +253,7 @@ export class CantiLettureService {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMass(date: string) {
|
||||
this.selectedMassDate.set(date);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { HttpClient, HttpEventType } from '@angular/common/http';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
export interface Canto {
|
||||
id: string;
|
||||
@@ -13,6 +14,9 @@ export interface Canto {
|
||||
id_momenti?: number[];
|
||||
data_update?: string;
|
||||
nonValidato?: boolean;
|
||||
isPersonal?: boolean;
|
||||
durata?: string;
|
||||
bpm?: number;
|
||||
}
|
||||
|
||||
export interface Indice {
|
||||
@@ -33,6 +37,7 @@ export interface CantoEseguito {
|
||||
export class CantiService {
|
||||
private http = inject(HttpClient);
|
||||
private storage = inject(Storage);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
public canti = signal<Canto[]>([]);
|
||||
@@ -43,8 +48,14 @@ export class CantiService {
|
||||
public momenti = signal<Indice[]>([]);
|
||||
public loading = signal<boolean>(false);
|
||||
public progress = signal<number>(0);
|
||||
public firstLoadCompleted = signal<boolean>(false);
|
||||
|
||||
private API_URL = 'https://www.canticristiani.it/api/canti.json';
|
||||
private getApiUrl(): string {
|
||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||
return isProduction
|
||||
? `${window.location.origin}/api/canti.json`
|
||||
: 'https://www.canticristiani.it/api/canti.json';
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
@@ -54,6 +65,20 @@ export class CantiService {
|
||||
const storage = await this.storage.create();
|
||||
this._storage = storage;
|
||||
await this.loadFromStorage();
|
||||
if (this.canti() && this.canti().length > 0) {
|
||||
this.firstLoadCompleted.set(true);
|
||||
} else {
|
||||
// First boot or data cleared: show setup loader immediately
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Setup in corso',
|
||||
phase: 'Fase: Setup',
|
||||
desc: 'Configurazione iniziale e caricamento canti...',
|
||||
percent: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -74,16 +99,22 @@ export class CantiService {
|
||||
this.loading.set(true);
|
||||
this.progress.set(0);
|
||||
|
||||
this.http.get(`${this.API_URL}?t=${Date.now()}`, {
|
||||
this.http.get(`${this.getApiUrl()}?t=${Date.now()}`, {
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}).subscribe({
|
||||
next: async (event: any) => {
|
||||
if (event.type === HttpEventType.DownloadProgress) {
|
||||
let pct = 0;
|
||||
if (event.total) {
|
||||
this.progress.set(Math.round((event.loaded / event.total) * 100));
|
||||
pct = Math.round((event.loaded / event.total) * 100);
|
||||
this.progress.set(pct);
|
||||
} else {
|
||||
this.progress.update(p => p < 90 ? p + 5 : p);
|
||||
pct = this.progress();
|
||||
}
|
||||
if (!this.firstLoadCompleted() && (window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({ percent: pct });
|
||||
}
|
||||
} else if (event.type === HttpEventType.Response) {
|
||||
const response = event.body;
|
||||
@@ -131,11 +162,13 @@ export class CantiService {
|
||||
}
|
||||
this.progress.set(100);
|
||||
this.loading.set(false);
|
||||
this.firstLoadCompleted.set(true);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch canti', error);
|
||||
this.loading.set(false);
|
||||
this.firstLoadCompleted.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,7 +186,10 @@ export class ComunitaService {
|
||||
link_youtube: cp.link_youtube || '',
|
||||
id_momenti: [],
|
||||
data_update: cp.data_update || '',
|
||||
nonValidato: true
|
||||
nonValidato: Number(cp.stato) === 10,
|
||||
isPersonal: true,
|
||||
durata: cp.durata || '',
|
||||
bpm: cp.bpm !== undefined && cp.bpm !== null ? Number(cp.bpm) : undefined
|
||||
}));
|
||||
|
||||
this.comunitaCode.set(trimmedCode);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FaceDetectorService {
|
||||
public isCameraActive = signal<boolean>(false);
|
||||
public currentTiltAngle = signal<number>(0);
|
||||
public isTilted = signal<boolean>(false);
|
||||
|
||||
private stream: MediaStream | null = null;
|
||||
private animFrameId: number | null = null;
|
||||
private faceMesh: any = null;
|
||||
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
|
||||
|
||||
// Gesture state machine
|
||||
private tiltStartTime: number = 0;
|
||||
private inCooldown: boolean = false;
|
||||
private lastTriggerTime: number = 0;
|
||||
private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability)
|
||||
private readonly TILT_HOLD_MS = 300; // How long to hold the tilt
|
||||
private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown
|
||||
private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Loads MediaPipe script dynamically if not already loaded.
|
||||
*/
|
||||
private loadScripts(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).FaceMesh) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const faceMeshScript = document.createElement('script');
|
||||
faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
|
||||
faceMeshScript.onload = () => resolve();
|
||||
faceMeshScript.onerror = (err) => reject(err);
|
||||
|
||||
document.head.appendChild(faceMeshScript);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or re-binds camera capture and face mesh tracking.
|
||||
* If a stream is already active (e.g. navigating between songs on iPad), reuses it
|
||||
* to avoid triggering repeated permission prompts on iOS/Safari.
|
||||
*/
|
||||
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> {
|
||||
this.onTiltCallback = onTilt;
|
||||
|
||||
try {
|
||||
await this.loadScripts();
|
||||
|
||||
const isStreamActive = this.stream &&
|
||||
this.stream.active &&
|
||||
this.stream.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!isStreamActive) {
|
||||
// Request camera permissions and stream ONCE
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 320 },
|
||||
height: { ideal: 240 },
|
||||
facingMode: 'user'
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
}
|
||||
|
||||
videoElement.srcObject = this.stream;
|
||||
videoElement.setAttribute('playsinline', 'true');
|
||||
(videoElement as any).playsInline = true;
|
||||
videoElement.muted = true;
|
||||
|
||||
try {
|
||||
await videoElement.play();
|
||||
} catch (playErr) {
|
||||
console.warn('[FaceDetector] Video play warning:', playErr);
|
||||
}
|
||||
|
||||
if (!this.faceMesh) {
|
||||
const FaceMeshLib = (window as any).FaceMesh;
|
||||
if (!FaceMeshLib) {
|
||||
throw new Error('MediaPipe FaceMesh library failed to initialize.');
|
||||
}
|
||||
|
||||
this.faceMesh = new FaceMeshLib({
|
||||
locateFile: (file: string) => `assets/mediapipe/${file}`
|
||||
});
|
||||
|
||||
this.faceMesh.setOptions({
|
||||
maxNumFaces: 1,
|
||||
refineLandmarks: false,
|
||||
minDetectionConfidence: 0.6,
|
||||
minTrackingConfidence: 0.6
|
||||
});
|
||||
|
||||
this.faceMesh.onResults((results: any) => {
|
||||
this.processLandmarks(results);
|
||||
});
|
||||
}
|
||||
|
||||
this.isCameraActive.set(true);
|
||||
|
||||
// Start custom frame processing loop
|
||||
this.stopFrameLoop();
|
||||
this.startFrameLoop(videoElement);
|
||||
|
||||
console.log('[FaceDetector] Face tracking started successfully.');
|
||||
} catch (err) {
|
||||
console.error('[FaceDetector] Failed to start face tracking:', err);
|
||||
this.stop();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private stopFrameLoop() {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startFrameLoop(videoElement: HTMLVideoElement) {
|
||||
let lastFrameTime = 0;
|
||||
const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM
|
||||
|
||||
const processFrame = async () => {
|
||||
if (!this.isCameraActive()) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFrameTime >= FRAME_INTERVAL_MS && this.faceMesh && videoElement && videoElement.readyState >= 2) {
|
||||
lastFrameTime = now;
|
||||
try {
|
||||
await this.faceMesh.send({ image: videoElement });
|
||||
} catch (err) {
|
||||
console.warn('[FaceDetector] Error processing frame:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isCameraActive()) {
|
||||
this.animFrameId = requestAnimationFrame(processFrame);
|
||||
}
|
||||
};
|
||||
|
||||
this.animFrameId = requestAnimationFrame(processFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes landmarks to calculate head tilt angle.
|
||||
*/
|
||||
private processLandmarks(results: any) {
|
||||
if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
|
||||
this.currentTiltAngle.set(0);
|
||||
this.isTilted.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const landmarks = results.multiFaceLandmarks[0];
|
||||
|
||||
// Left eye corner (landmark 33) and right eye corner (landmark 263)
|
||||
const leftEye = landmarks[33];
|
||||
const rightEye = landmarks[263];
|
||||
|
||||
if (!leftEye || !rightEye) return;
|
||||
|
||||
// Calculate angle in degrees
|
||||
const dy = rightEye.y - leftEye.y;
|
||||
const dx = rightEye.x - leftEye.x;
|
||||
|
||||
// Normalize angle (roll)
|
||||
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||
|
||||
// Smooth angle updates
|
||||
this.currentTiltAngle.set(Math.round(angle));
|
||||
|
||||
const absAngle = Math.abs(angle);
|
||||
|
||||
if (absAngle > this.TILT_THRESHOLD) {
|
||||
this.isTilted.set(true);
|
||||
|
||||
if (!this.inCooldown && (Date.now() - this.lastTriggerTime > this.TRIGGER_COOLDOWN_MS)) {
|
||||
if (this.tiltStartTime === 0) {
|
||||
this.tiltStartTime = Date.now();
|
||||
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
|
||||
// Trigger the tilt gesture!
|
||||
const direction = angle > 0 ? 'prev' : 'next';
|
||||
console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`);
|
||||
if (this.onTiltCallback) {
|
||||
this.onTiltCallback(direction);
|
||||
}
|
||||
this.inCooldown = true;
|
||||
this.tiltStartTime = 0;
|
||||
this.lastTriggerTime = Date.now();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.isTilted.set(false);
|
||||
this.tiltStartTime = 0;
|
||||
|
||||
// Reset cooldown when the head returns near the center
|
||||
if (absAngle < this.RETURN_THRESHOLD) {
|
||||
this.inCooldown = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the frame tracking loop without killing the underlying media stream hardware.
|
||||
*/
|
||||
pause() {
|
||||
this.stopFrameLoop();
|
||||
this.isCameraActive.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops camera capture and releases face mesh & hardware stream resources.
|
||||
*/
|
||||
stop() {
|
||||
this.pause();
|
||||
this.currentTiltAngle.set(0);
|
||||
this.isTilted.set(false);
|
||||
this.inCooldown = false;
|
||||
this.tiltStartTime = 0;
|
||||
this.lastTriggerTime = 0;
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
if (this.faceMesh) {
|
||||
try {
|
||||
this.faceMesh.close();
|
||||
} catch (e) {}
|
||||
this.faceMesh = null;
|
||||
}
|
||||
|
||||
this.onTiltCallback = null;
|
||||
console.log('[FaceDetector] Face tracking fully stopped.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LyricsParserService } from './lyrics-parser.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
describe('LyricsParserService - Hybrid Notation', () => {
|
||||
let service: LyricsParserService;
|
||||
let mockSettingsService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSettingsService = {
|
||||
chordNotationPreference: jasmine.createSpy('chordNotationPreference').and.returnValue('diesis')
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
LyricsParserService,
|
||||
{ provide: SettingsService, useValue: mockSettingsService }
|
||||
]
|
||||
});
|
||||
service = TestBed.inject(LyricsParserService);
|
||||
});
|
||||
|
||||
it('should parse simple text with memorize, replay, copy, paste', () => {
|
||||
const rawSong = `
|
||||
{start_verse}{memorize/m1}
|
||||
Ralle[RE]griamoci, [LA]
|
||||
non [SOL]c’è spazio alla tri[RE]stezza in questo gior[LA]no [SOL]
|
||||
{end_verse}
|
||||
|
||||
{start_chorus}{copy/rit}
|
||||
Gloria a [RE]Te Emma[SOL]nue-[LA]le
|
||||
{end_chorus}
|
||||
|
||||
{start_verse}{replay/m1}
|
||||
Rallegri*amoci,*
|
||||
Egli *viene a libe*rarci da ogni *male.*
|
||||
{end_verse}
|
||||
|
||||
{start_chorus}{paste/rit}
|
||||
{end_chorus}
|
||||
`;
|
||||
|
||||
// 1. Test parsing with chords enabled
|
||||
const sections = service.parseAccordi(rawSong);
|
||||
expect(sections.length).toBe(4);
|
||||
|
||||
// Verse 1 (memorize)
|
||||
expect(sections[0].type).toBe('verse');
|
||||
expect(sections[0].lines[0].text).toBe('Rallegriamoci, ');
|
||||
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[0].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[0].lines[0].segments[2].chord).toBe('LA');
|
||||
|
||||
// Chorus 1 (copy)
|
||||
expect(sections[1].type).toBe('chorus');
|
||||
expect(sections[1].lines[0].text).toBe('Gloria a Te Emmanue-le');
|
||||
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[1].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[1].lines[0].segments[2].chord).toBe('SOL');
|
||||
expect(sections[1].lines[0].segments[3].chord).toBe('LA');
|
||||
|
||||
// Verse 2 (replay)
|
||||
expect(sections[2].type).toBe('verse');
|
||||
expect(sections[2].lines[0].text).toBe('Rallegriamoci,');
|
||||
expect(sections[2].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[2].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[2].lines[0].segments[2].chord).toBe('LA');
|
||||
|
||||
expect(sections[2].lines[1].text).toBe('Egli viene a liberarci da ogni male.');
|
||||
expect(sections[2].lines[1].segments[0].chord).toBeUndefined();
|
||||
expect(sections[2].lines[1].segments[1].chord).toBe('SOL');
|
||||
expect(sections[2].lines[1].segments[2].chord).toBe('RE');
|
||||
expect(sections[2].lines[1].segments[3].chord).toBe('LA');
|
||||
expect(sections[2].lines[1].segments[4].chord).toBe('SOL');
|
||||
|
||||
// Chorus 2 (paste)
|
||||
expect(sections[3].type).toBe('chorus');
|
||||
expect(sections[3].lines[0].text).toBe('Gloria a Te Emmanue-le');
|
||||
expect(sections[3].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[3].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[3].lines[0].segments[2].chord).toBe('SOL');
|
||||
expect(sections[3].lines[0].segments[3].chord).toBe('LA');
|
||||
});
|
||||
|
||||
it('should strip chords in text-only mode and remove asterisks', () => {
|
||||
const rawSong = `
|
||||
{start_verse}{memorize/m1}
|
||||
Ralle[RE]griamoci, [LA]
|
||||
{end_verse}
|
||||
|
||||
{start_verse}{replay/m1}
|
||||
Rallegri*amoci,*
|
||||
{end_verse}
|
||||
`;
|
||||
const sections = service.parseText(rawSong);
|
||||
expect(sections.length).toBe(2);
|
||||
|
||||
expect(sections[0].lines[0].text).toBe('Rallegriamoci,');
|
||||
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
|
||||
|
||||
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
|
||||
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
||||
});
|
||||
it('should format sections outside of soc/eoc tags as verses', () => {
|
||||
const rawSong = `
|
||||
{c:Intro:} [RE]
|
||||
|
||||
[RE]Guardami Signor
|
||||
{soc}
|
||||
Abba Padre!
|
||||
{eoc}
|
||||
[RE]Più solo non sarò
|
||||
`;
|
||||
const sections = service.parseAccordi(rawSong);
|
||||
// There should be three sections:
|
||||
// 1. Verse (Intro & Guardami Signor)
|
||||
// 2. Chorus (Abba Padre)
|
||||
// 3. Verse (Più solo non sarò)
|
||||
expect(sections.length).toBe(3);
|
||||
expect(sections[0].type).toBe('verse');
|
||||
expect(sections[0].lines[0].text).toBe('Intro ');
|
||||
expect(sections[0].lines[0].segments[0].text).toBe('Intro ');
|
||||
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[0].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[0].lines[1].text).toBe('Guardami Signor');
|
||||
expect(sections[1].type).toBe('chorus');
|
||||
expect(sections[2].type).toBe('verse');
|
||||
});
|
||||
|
||||
it('should parse vertical bars | as chords', () => {
|
||||
const rawSong = `
|
||||
[SOL] | [RE] | [DO] | [RE]
|
||||
| | | |
|
||||
`;
|
||||
const sections = service.parseAccordi(rawSong);
|
||||
expect(sections.length).toBe(1);
|
||||
const line1 = sections[0].lines[0];
|
||||
const line2 = sections[0].lines[1];
|
||||
|
||||
// Check first line: SOL | RE | DO | RE
|
||||
// All components should be parsed as chords
|
||||
expect(line1.segments.map(s => s.chord)).toEqual(['SOL', '|', 'RE', '|', 'DO', '|', 'RE']);
|
||||
expect(line1.segments.map(s => s.text.trim())).toEqual(['', '', '', '', '', '', '']);
|
||||
|
||||
// Check second line: | | | |
|
||||
expect(line2.segments.map(s => s.chord)).toEqual(['|', '|', '|', '|']);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
export interface ChordSegment {
|
||||
text: string;
|
||||
@@ -20,6 +21,7 @@ export interface ParsedSection {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LyricsParserService {
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
/**
|
||||
* Parse plain text (campo 'testo') into structured sections.
|
||||
@@ -43,41 +45,83 @@ export class LyricsParserService {
|
||||
let currentLines: ParsedLine[] = [];
|
||||
let verseNumCounter = 0;
|
||||
|
||||
const memories = new Map<string, string[]>();
|
||||
const copies = new Map<string, string[]>();
|
||||
|
||||
let currentAction: { type: 'memorize' | 'replay' | 'copy' | 'paste'; key: string } | null = null;
|
||||
let currentRawLines: string[] = [];
|
||||
|
||||
const lines = raw.split('\n');
|
||||
const startTagRegex = /^\{(start_verse|start_chorus|start_verse_num|sov|soc)\}(?:\{([a-z]+)\/([a-zA-Z0-9_-]+)\})?$/;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Detect section start tags
|
||||
if (trimmed === '{start_verse}' || trimmed === '{sov}') {
|
||||
const startMatch = trimmed.match(startTagRegex);
|
||||
if (startMatch) {
|
||||
this.pushSection(sections, currentType, currentLines);
|
||||
|
||||
const tag = startMatch[1];
|
||||
if (tag === 'start_verse' || tag === 'sov') {
|
||||
currentType = 'verse';
|
||||
currentLines = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
|
||||
this.pushSection(sections, currentType, currentLines);
|
||||
} else if (tag === 'start_chorus' || tag === 'soc') {
|
||||
currentType = 'chorus';
|
||||
currentLines = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed === '{start_verse_num}') {
|
||||
this.pushSection(sections, currentType, currentLines);
|
||||
} else if (tag === 'start_verse_num') {
|
||||
currentType = 'verse_num';
|
||||
verseNumCounter++;
|
||||
}
|
||||
|
||||
currentLines = [];
|
||||
currentRawLines = [];
|
||||
|
||||
if (startMatch[2] && startMatch[3]) {
|
||||
currentAction = {
|
||||
type: startMatch[2] as any,
|
||||
key: startMatch[3]
|
||||
};
|
||||
|
||||
if (currentAction.type === 'paste') {
|
||||
const pastedRawLines = copies.get(currentAction.key) || [];
|
||||
for (const pLine of pastedRawLines) {
|
||||
if (withChords) {
|
||||
currentLines.push(this.parseChordLine(pLine));
|
||||
} else {
|
||||
const cleanLine = pLine.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (cleanLine.length > 0) {
|
||||
currentLines.push({
|
||||
text: cleanLine,
|
||||
segments: [{ text: cleanLine }]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentAction = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect section end tags — just skip them
|
||||
// Detect section end tags
|
||||
if (trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}') {
|
||||
if (currentAction) {
|
||||
if (currentAction.type === 'memorize') {
|
||||
memories.set(currentAction.key, [...currentRawLines]);
|
||||
} else if (currentAction.type === 'copy') {
|
||||
copies.set(currentAction.key, [...currentRawLines]);
|
||||
}
|
||||
}
|
||||
|
||||
this.pushSection(sections, currentType, currentLines, currentType === 'verse_num' ? verseNumCounter : undefined);
|
||||
currentLines = [];
|
||||
currentRawLines = [];
|
||||
currentAction = null;
|
||||
currentType = 'verse';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip structural tags (already handled above)
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}') && !trimmed.startsWith('{c:') && !trimmed.startsWith('{comment:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -86,12 +130,38 @@ export class LyricsParserService {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolvedLine = line;
|
||||
if (currentAction && currentAction.type === 'replay') {
|
||||
const memLines = memories.get(currentAction.key) || [];
|
||||
const lineIndex = currentRawLines.length;
|
||||
if (lineIndex < memLines.length) {
|
||||
const memLine = memLines[lineIndex];
|
||||
const chordMatches = [...memLine.matchAll(/\[([^\]]+)\]/g)].map(m => m[0]);
|
||||
let chordIdx = 0;
|
||||
resolvedLine = line.replace(/\*/g, () => {
|
||||
if (chordIdx < chordMatches.length) {
|
||||
return chordMatches[chordIdx++];
|
||||
}
|
||||
return '';
|
||||
});
|
||||
} else {
|
||||
resolvedLine = line.replace(/\*/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean comment tags: {c:Text} or {comment:Text} -> Text (removing trailing colon if any)
|
||||
resolvedLine = resolvedLine.replace(/\{(?:c|comment):([^}]+)\}/g, (match, p1) => {
|
||||
return p1.trim().replace(/:$/, '');
|
||||
});
|
||||
|
||||
currentRawLines.push(line);
|
||||
|
||||
// Parse line
|
||||
if (withChords) {
|
||||
currentLines.push(this.parseChordLine(line));
|
||||
currentLines.push(this.parseChordLine(resolvedLine));
|
||||
} else {
|
||||
// CLEAN CHORDS in text-only mode: remove [anything]
|
||||
const cleanLine = line.replace(/\[[^\]]*\]/g, '').trim();
|
||||
const cleanLine = resolvedLine.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (cleanLine.length > 0) {
|
||||
currentLines.push({
|
||||
text: cleanLine,
|
||||
@@ -124,9 +194,13 @@ export class LyricsParserService {
|
||||
* sei Re Gesù[SOL] → text "sei Re Gesù" then chord "SOL" with empty text
|
||||
*/
|
||||
parseChordLine(line: string): ParsedLine {
|
||||
// Treat vertical bars (|) as chords instead of text
|
||||
let prepared = line.replace(/\[\|\]/g, '|');
|
||||
prepared = prepared.replace(/\|/g, '[|]');
|
||||
|
||||
const segments: ChordSegment[] = [];
|
||||
// Clean up non-breaking spaces
|
||||
const cleaned = line.replace(/\u00a0/g, ' ').trim();
|
||||
const cleaned = prepared.replace(/\u00a0/g, ' ').trim();
|
||||
|
||||
// Regex to match [CHORD] tags and text between them
|
||||
const chordRegex = /\[([^\]]+)\]/g;
|
||||
@@ -147,7 +221,7 @@ export class LyricsParserService {
|
||||
}
|
||||
|
||||
// Add the chord as a new segment (text will be filled by next text chunk)
|
||||
segments.push({ chord: match[1], text: '' });
|
||||
segments.push({ chord: this.transposeChord(match[1], 0), text: '' });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
@@ -180,7 +254,13 @@ export class LyricsParserService {
|
||||
* Handles Italian notation.
|
||||
*/
|
||||
transposeChord(chord: string, semitones: number): string {
|
||||
if (!chord || semitones === 0) return chord;
|
||||
if (!chord) return chord;
|
||||
if (chord === '|') return '|';
|
||||
|
||||
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
|
||||
chord = chord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
|
||||
return p1 + (p2 || '') + 'm' + (p3 || '');
|
||||
});
|
||||
|
||||
// Handle slash chords (e.g., DO/SOL)
|
||||
if (chord.includes('/')) {
|
||||
@@ -193,8 +273,9 @@ export class LyricsParserService {
|
||||
let root = '';
|
||||
let suffix = '';
|
||||
|
||||
const upperChord = chord.toUpperCase();
|
||||
for (const r of possibleRoots) {
|
||||
if (chord.startsWith(r)) {
|
||||
if (upperChord.startsWith(r.toUpperCase())) {
|
||||
root = r;
|
||||
suffix = chord.substring(r.length);
|
||||
break;
|
||||
@@ -203,16 +284,40 @@ export class LyricsParserService {
|
||||
|
||||
if (!root) return chord;
|
||||
|
||||
let index = this.scale.indexOf(root);
|
||||
if (index === -1) index = this.flatScale.indexOf(root);
|
||||
const upperRoot = root.toUpperCase();
|
||||
let index = this.scale.indexOf(upperRoot);
|
||||
if (index === -1) {
|
||||
index = this.flatScale.findIndex(n => n.toUpperCase() === upperRoot);
|
||||
}
|
||||
if (index === -1) return chord;
|
||||
|
||||
let newIndex = (index + semitones) % 12;
|
||||
if (newIndex < 0) newIndex += 12;
|
||||
|
||||
// Preserve the original notation style (sharp or flat) if possible
|
||||
const useFlat = this.flatScale.includes(root);
|
||||
const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
|
||||
// Decide flat vs sharp notation based on SettingsService preference:
|
||||
const pref = this.settingsService.chordNotationPreference();
|
||||
let useFlat = false;
|
||||
if (pref === 'diesis') {
|
||||
useFlat = false;
|
||||
} else if (pref === 'bemolle') {
|
||||
useFlat = true;
|
||||
} else {
|
||||
// Fallback/Default logic
|
||||
if (root.includes('#')) {
|
||||
useFlat = false;
|
||||
} else if (root.toLowerCase().includes('b')) {
|
||||
useFlat = true;
|
||||
} else {
|
||||
useFlat = semitones < 0;
|
||||
}
|
||||
}
|
||||
|
||||
let newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
|
||||
if (newRoot === 'LA#') {
|
||||
newRoot = 'SIb';
|
||||
} else if (newRoot === 'RE#') {
|
||||
newRoot = 'MIb';
|
||||
}
|
||||
|
||||
return newRoot + suffix;
|
||||
}
|
||||
@@ -221,8 +326,6 @@ export class LyricsParserService {
|
||||
* Transpose all chords in a parsed structure.
|
||||
*/
|
||||
transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] {
|
||||
if (semitones === 0) return sections;
|
||||
|
||||
return sections.map(section => ({
|
||||
...section,
|
||||
lines: section.lines.map(line => ({
|
||||
@@ -234,4 +337,77 @@ export class LyricsParserService {
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if the segment at the given index is contiguous with the next segment.
|
||||
* This means the text is split within a word (e.g. by a chord tag like "rima[RE]ne").
|
||||
*/
|
||||
isContiguousNext(segments: ChordSegment[], index: number): boolean {
|
||||
if (!segments || index >= segments.length - 1) return false;
|
||||
|
||||
// Se sia il segmento corrente che il successivo hanno un accordo, non sono contigui (vogliamo dello spazio tra loro)
|
||||
if (segments[index].chord && segments[index + 1].chord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the next segment with non-empty text
|
||||
let nextWithText: ChordSegment | null = null;
|
||||
for (let i = index + 1; i < segments.length; i++) {
|
||||
if (segments[i].text && segments[i].text.length > 0) {
|
||||
nextWithText = segments[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const currentText = segments[index].text || '';
|
||||
if (!currentText) {
|
||||
// If current segment has no text, it should have no margin-right to not add extra spacing
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!nextWithText) return false;
|
||||
|
||||
const endsWithNonSpace = !/\s$/.test(currentText);
|
||||
const startsWithNonSpace = !/^\s/.test(nextWithText.text);
|
||||
|
||||
return endsWithNonSpace && startsWithNonSpace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduce the musical key (tonality) of the song by looking at the first chord.
|
||||
*/
|
||||
deduceTonality(raw: string): string | null {
|
||||
if (!raw) return null;
|
||||
const matches = [...raw.matchAll(/\[([^\]]+)\]/g)];
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
// Get the first chord
|
||||
let firstChord = matches[0][1].trim();
|
||||
if (firstChord.includes('/')) {
|
||||
firstChord = firstChord.split('/')[0].trim();
|
||||
}
|
||||
|
||||
// Normalize minor indicators (uppercase M/N to m)
|
||||
firstChord = firstChord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
|
||||
return p1 + (p2 || '') + 'm';
|
||||
});
|
||||
|
||||
// Find the root chord name matching the scales
|
||||
const possibleRoots = [...this.scale, ...this.flatScale].sort((a, b) => b.length - a.length);
|
||||
let root = '';
|
||||
const upperChord = firstChord.toUpperCase();
|
||||
for (const r of possibleRoots) {
|
||||
if (upperChord.startsWith(r.toUpperCase())) {
|
||||
root = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!root) return null;
|
||||
|
||||
// Check if it is a minor chord
|
||||
const isMinor = firstChord.toLowerCase().includes('m') || firstChord.includes('-');
|
||||
return root + (isMinor ? 'm' : '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { CantiService } from './canti.service';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import { PlaylistService } from './playlist.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -10,6 +11,7 @@ export class MediaSessionService {
|
||||
private cantiService = inject(CantiService);
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
private playlistService = inject(PlaylistService);
|
||||
|
||||
public updateMetadata(cantoId: string) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
@@ -21,6 +23,12 @@ export class MediaSessionService {
|
||||
if (!canto) {
|
||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
||||
}
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (!canto) return;
|
||||
|
||||
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { Injectable, signal, inject, Injector } from '@angular/core';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { Canto, CantiService } from './canti.service';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { PlaylistService } from './playlist.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -11,20 +12,26 @@ export class MyCantiService {
|
||||
private storage = inject(Storage);
|
||||
private cantiService = inject(CantiService);
|
||||
private toastController = inject(ToastController);
|
||||
private injector = inject(Injector);
|
||||
private playlistService!: PlaylistService;
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
public myCanti = signal<Canto[]>([]);
|
||||
private initPromise!: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
this.initPromise = this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
async init(): Promise<void> {
|
||||
this._storage = this.cantiService.getStorage();
|
||||
if (!this._storage) {
|
||||
// If CantiService hasn't initialized storage yet, wait a bit
|
||||
setTimeout(() => this.init(), 500);
|
||||
return;
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
await this.init();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
const saved = await this._storage.get('my-canti');
|
||||
if (saved) {
|
||||
@@ -32,9 +39,52 @@ export class MyCantiService {
|
||||
}
|
||||
}
|
||||
|
||||
async saveCanto(canto: Partial<Canto>) {
|
||||
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
|
||||
await this.initPromise;
|
||||
const current = this.myCanti();
|
||||
const newCanto: Canto = {
|
||||
let updated: Canto[];
|
||||
let targetCanto: Canto;
|
||||
|
||||
if (canto.id && canto.id.startsWith('my_')) {
|
||||
// Update existing song
|
||||
updated = current.map(c => {
|
||||
if (c.id === canto.id) {
|
||||
targetCanto = {
|
||||
...c,
|
||||
titolo: canto.titolo || c.titolo,
|
||||
testo: canto.testo || c.testo,
|
||||
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
|
||||
autore: canto.autore !== undefined ? canto.autore : c.autore,
|
||||
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
|
||||
id_momenti: canto.id_momenti || c.id_momenti,
|
||||
durata: canto.durata !== undefined ? canto.durata : c.durata,
|
||||
bpm: canto.bpm !== undefined ? canto.bpm : c.bpm
|
||||
};
|
||||
return targetCanto;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
// Fallback if not found in list (should not happen normally)
|
||||
if (!updated.some(c => c.id === canto.id)) {
|
||||
const numericId = canto.id ? Number(canto.id.replace('my_', '')) : NaN;
|
||||
const idCanti = isNaN(numericId) ? Date.now() : numericId;
|
||||
targetCanto = {
|
||||
id: canto.id,
|
||||
id_canti: canto.id_canti || idCanti,
|
||||
titolo: canto.titolo || 'Senza Titolo',
|
||||
testo: canto.testo || '',
|
||||
accordi: canto.accordi,
|
||||
autore: canto.autore,
|
||||
link_youtube: canto.link_youtube,
|
||||
id_momenti: canto.id_momenti || [],
|
||||
durata: canto.durata,
|
||||
bpm: canto.bpm
|
||||
};
|
||||
updated.push(targetCanto);
|
||||
}
|
||||
} else {
|
||||
// Create new song
|
||||
targetCanto = {
|
||||
id: `my_${Date.now()}`,
|
||||
id_canti: Date.now(), // Fake ID for internal logic
|
||||
titolo: canto.titolo || 'Senza Titolo',
|
||||
@@ -42,25 +92,43 @@ export class MyCantiService {
|
||||
accordi: canto.accordi,
|
||||
autore: canto.autore,
|
||||
link_youtube: canto.link_youtube,
|
||||
id_momenti: canto.id_momenti || []
|
||||
id_momenti: canto.id_momenti || [],
|
||||
durata: canto.durata,
|
||||
bpm: canto.bpm
|
||||
};
|
||||
updated = [...current, targetCanto];
|
||||
}
|
||||
|
||||
const updated = [...current, newCanto];
|
||||
this.myCanti.set(updated);
|
||||
await this._storage?.set('my-canti', updated);
|
||||
|
||||
// Sincronizza automaticamente in background
|
||||
if (!this.playlistService) {
|
||||
this.playlistService = this.injector.get(PlaylistService);
|
||||
}
|
||||
this.playlistService.syncLocalDataToServer();
|
||||
|
||||
const toast = await this.toastController.create({
|
||||
message: 'Canto salvato nei "Miei Canti"!',
|
||||
duration: 2000,
|
||||
color: 'success'
|
||||
});
|
||||
toast.present();
|
||||
|
||||
return targetCanto!;
|
||||
}
|
||||
|
||||
async deleteCanto(id: string) {
|
||||
await this.initPromise;
|
||||
const updated = this.myCanti().filter(c => c.id !== id);
|
||||
this.myCanti.set(updated);
|
||||
await this._storage?.set('my-canti', updated);
|
||||
|
||||
// Sincronizza automaticamente in background
|
||||
if (!this.playlistService) {
|
||||
this.playlistService = this.injector.get(PlaylistService);
|
||||
}
|
||||
this.playlistService.syncLocalDataToServer();
|
||||
}
|
||||
|
||||
async sendAllMyCanti() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,41 @@ export class SettingsService {
|
||||
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
||||
public showUpdateDate = signal<boolean>(true);
|
||||
|
||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
||||
public enableStandardAutoscroll = signal<boolean>(true);
|
||||
/** Visualizza durata, bpm e tonalità sotto autore / titolo: true = attivo */
|
||||
public showDurationBpmTonality = signal<boolean>(true);
|
||||
|
||||
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
|
||||
public enableAcousticAutoscroll = signal<boolean>(false);
|
||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
||||
public enableStandardAutoscroll = signal<boolean>(false);
|
||||
|
||||
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
|
||||
public enableVisualAutoscroll = signal<boolean>(true);
|
||||
|
||||
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
|
||||
public cameraNavigationActive = signal<boolean>(false);
|
||||
|
||||
/** Preferenza notazione accordi: diesis o bemolle */
|
||||
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
||||
|
||||
/** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */
|
||||
public karaokePageScrollMode = signal<boolean>(false);
|
||||
|
||||
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
|
||||
public landscapeProjectionEnabled = signal<boolean>(false);
|
||||
|
||||
/** Schermo nero per l'ascolto delle playlist in macchina: true = attivo */
|
||||
public carModeBlackScreen = signal<boolean>(false);
|
||||
|
||||
/** Global zoom/font size factor for song presentation */
|
||||
public globalZoom = signal<number>(1.0);
|
||||
|
||||
/** Identificativo utente univoco per la gestione delle comunità */
|
||||
public userUuid = signal<string>('');
|
||||
|
||||
/** Identificativo originario assegnato alla prima installazione */
|
||||
public originalUserUuid = signal<string>('');
|
||||
|
||||
/** Nome associato all'identità utente */
|
||||
public userName = signal<string>('');
|
||||
|
||||
private wakeLock: any = null;
|
||||
|
||||
@@ -55,8 +85,38 @@ export class SettingsService {
|
||||
public isStandalone = signal<boolean>(false);
|
||||
public isIos = signal<boolean>(false);
|
||||
public isAndroid = signal<boolean>(false);
|
||||
public isVersionCheckComplete = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Gestione/Generazione ID utente univoco
|
||||
let savedUuid = localStorage.getItem('user-uuid');
|
||||
if (!savedUuid) {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
savedUuid = crypto.randomUUID();
|
||||
} else {
|
||||
savedUuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
localStorage.setItem('user-uuid', savedUuid);
|
||||
}
|
||||
this.userUuid.set(savedUuid);
|
||||
|
||||
// Salvataggio dell'ID originario (alla prima installazione) se non è già presente
|
||||
let originalUuid = localStorage.getItem('original-user-uuid');
|
||||
if (!originalUuid) {
|
||||
originalUuid = savedUuid;
|
||||
localStorage.setItem('original-user-uuid', originalUuid);
|
||||
}
|
||||
this.originalUserUuid.set(originalUuid);
|
||||
|
||||
const savedName = localStorage.getItem('user-name');
|
||||
if (savedName) {
|
||||
this.userName.set(savedName);
|
||||
}
|
||||
|
||||
// Detect PWA status
|
||||
try {
|
||||
this.isStandalone.set(
|
||||
@@ -80,6 +140,8 @@ export class SettingsService {
|
||||
this.deferredPrompt.set(e);
|
||||
// Update UI notify the user they can install the PWA
|
||||
this.showInstallButton.set(true);
|
||||
// Se scatta l'evento di installazione, l'app NON è attualmente installata
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
@@ -90,7 +152,7 @@ export class SettingsService {
|
||||
});
|
||||
|
||||
// Migration: force settings defaults once for existing users to match the new rules
|
||||
const migrationKey = 'defaults-migrated-20260521';
|
||||
const migrationKey = 'defaults-migrated-20260605';
|
||||
if (localStorage.getItem(migrationKey) !== 'true') {
|
||||
localStorage.setItem('show-chords-default', 'true');
|
||||
localStorage.setItem('fullscreen-mode', this.isIos().toString());
|
||||
@@ -101,8 +163,10 @@ export class SettingsService {
|
||||
localStorage.setItem('invio-dati-statistici', 'false');
|
||||
localStorage.setItem('show-tags-in-list', 'true');
|
||||
localStorage.setItem('show-update-date', 'true');
|
||||
localStorage.setItem('enable-standard-autoscroll', 'true');
|
||||
localStorage.setItem('enable-acoustic-autoscroll', 'false');
|
||||
localStorage.setItem('show-duration-bpm-tonality', 'true');
|
||||
localStorage.setItem('enable-standard-autoscroll', 'false');
|
||||
localStorage.setItem('enable-visual-autoscroll', 'true');
|
||||
localStorage.setItem('chord-notation-preference', 'diesis');
|
||||
|
||||
// ThemeService high contrast default
|
||||
localStorage.setItem('high-contrast', 'true');
|
||||
@@ -110,6 +174,7 @@ export class SettingsService {
|
||||
localStorage.setItem(migrationKey, 'true');
|
||||
}
|
||||
|
||||
|
||||
const savedChords = localStorage.getItem('show-chords-default');
|
||||
if (savedChords !== null) {
|
||||
this.showChordsDefault.set(savedChords === 'true');
|
||||
@@ -138,12 +203,8 @@ export class SettingsService {
|
||||
this.autoAdvance.set(true);
|
||||
}
|
||||
|
||||
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
|
||||
if (savedKeepScreenOn !== null) {
|
||||
this.keepScreenOn.set(savedKeepScreenOn === 'true');
|
||||
} else {
|
||||
// Keep screen always on by default and always active
|
||||
this.keepScreenOn.set(true);
|
||||
}
|
||||
|
||||
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
|
||||
if (savedComunitaEnabled !== null) {
|
||||
@@ -173,18 +234,61 @@ export class SettingsService {
|
||||
this.showUpdateDate.set(true);
|
||||
}
|
||||
|
||||
const savedShowDurBpmTon = localStorage.getItem('show-duration-bpm-tonality');
|
||||
if (savedShowDurBpmTon !== null) {
|
||||
this.showDurationBpmTonality.set(savedShowDurBpmTon === 'true');
|
||||
} else {
|
||||
this.showDurationBpmTonality.set(true);
|
||||
}
|
||||
|
||||
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
||||
if (savedStandardAutoscroll !== null) {
|
||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||
} else {
|
||||
this.enableStandardAutoscroll.set(true);
|
||||
this.enableStandardAutoscroll.set(false);
|
||||
}
|
||||
|
||||
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
|
||||
if (savedAcousticAutoscroll !== null) {
|
||||
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
|
||||
const savedVisualAutoscroll = localStorage.getItem('enable-visual-autoscroll');
|
||||
if (savedVisualAutoscroll !== null) {
|
||||
this.enableVisualAutoscroll.set(savedVisualAutoscroll === 'true');
|
||||
} else {
|
||||
this.enableAcousticAutoscroll.set(false);
|
||||
this.enableVisualAutoscroll.set(true);
|
||||
}
|
||||
|
||||
const savedNotation = localStorage.getItem('chord-notation-preference');
|
||||
if (savedNotation !== null) {
|
||||
this.chordNotationPreference.set(savedNotation === 'bemolle' ? 'bemolle' : 'diesis');
|
||||
} else {
|
||||
this.chordNotationPreference.set('diesis');
|
||||
}
|
||||
|
||||
const savedKaraokePageScrollMode = localStorage.getItem('karaoke-page-scroll-mode');
|
||||
if (savedKaraokePageScrollMode !== null) {
|
||||
this.karaokePageScrollMode.set(savedKaraokePageScrollMode === 'true');
|
||||
} else {
|
||||
this.karaokePageScrollMode.set(false);
|
||||
}
|
||||
|
||||
const savedLandscapeProjectionEnabled = localStorage.getItem('landscape-projection-enabled');
|
||||
if (savedLandscapeProjectionEnabled !== null) {
|
||||
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
|
||||
} else {
|
||||
this.landscapeProjectionEnabled.set(false);
|
||||
}
|
||||
|
||||
const savedCarModeBlackScreen = localStorage.getItem('car-mode-black-screen');
|
||||
if (savedCarModeBlackScreen !== null) {
|
||||
this.carModeBlackScreen.set(savedCarModeBlackScreen === 'true');
|
||||
} else {
|
||||
this.carModeBlackScreen.set(false);
|
||||
}
|
||||
|
||||
const savedGlobalZoom = localStorage.getItem('global-zoom');
|
||||
if (savedGlobalZoom !== null) {
|
||||
const parsed = parseFloat(savedGlobalZoom);
|
||||
this.globalZoom.set(isNaN(parsed) ? 1.0 : parsed);
|
||||
} else {
|
||||
this.globalZoom.set(1.0);
|
||||
}
|
||||
|
||||
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||
@@ -210,29 +314,6 @@ export class SettingsService {
|
||||
effect(() => {
|
||||
const mode = this.fullscreenMode();
|
||||
localStorage.setItem('fullscreen-mode', mode.toString());
|
||||
|
||||
const isFs = !!(
|
||||
document.fullscreenElement ||
|
||||
(document as any).webkitFullscreenElement ||
|
||||
(document as any).mozFullScreenElement ||
|
||||
(document as any).msFullscreenElement
|
||||
);
|
||||
|
||||
if (mode && !isFs) {
|
||||
const docEl = document.documentElement as any;
|
||||
if (docEl.requestFullscreen) {
|
||||
docEl.requestFullscreen().catch((err: any) => console.log('Request fs ignored', err));
|
||||
} else if (docEl.webkitRequestFullscreen) {
|
||||
docEl.webkitRequestFullscreen();
|
||||
}
|
||||
} else if (!mode && isFs) {
|
||||
const doc = document as any;
|
||||
if (doc.exitFullscreen) {
|
||||
doc.exitFullscreen().catch((err: any) => console.log('Exit fs ignored', err));
|
||||
} else if (doc.webkitExitFullscreen) {
|
||||
doc.webkitExitFullscreen();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -247,12 +328,47 @@ export class SettingsService {
|
||||
localStorage.setItem('show-update-date', this.showUpdateDate().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('show-duration-bpm-tonality', this.showDurationBpmTonality().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString());
|
||||
localStorage.setItem('enable-visual-autoscroll', this.enableVisualAutoscroll().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('chord-notation-preference', this.chordNotationPreference());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('karaoke-page-scroll-mode', this.karaokePageScrollMode().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('landscape-projection-enabled', this.landscapeProjectionEnabled().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('car-mode-black-screen', this.carModeBlackScreen().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('global-zoom', this.globalZoom().toString());
|
||||
});
|
||||
|
||||
const savedCameraNavActive = localStorage.getItem('camera-navigation-active');
|
||||
if (savedCameraNavActive !== null) {
|
||||
this.cameraNavigationActive.set(savedCameraNavActive === 'true');
|
||||
} else {
|
||||
this.cameraNavigationActive.set(false);
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -291,9 +407,6 @@ export class SettingsService {
|
||||
localStorage.setItem('show-editor', newValue.toString());
|
||||
}
|
||||
|
||||
toggleKeepScreenOn() {
|
||||
this.keepScreenOn.update(v => !v);
|
||||
}
|
||||
|
||||
toggleComunitaEnabled() {
|
||||
const newValue = !this.comunitaEnabled();
|
||||
@@ -369,22 +482,64 @@ export class SettingsService {
|
||||
localStorage.setItem('show-update-date', newValue.toString());
|
||||
}
|
||||
|
||||
toggleShowDurationBpmTonality() {
|
||||
const newValue = !this.showDurationBpmTonality();
|
||||
this.showDurationBpmTonality.set(newValue);
|
||||
localStorage.setItem('show-duration-bpm-tonality', newValue.toString());
|
||||
}
|
||||
|
||||
toggleStandardAutoscroll() {
|
||||
const newValue = !this.enableStandardAutoscroll();
|
||||
this.enableStandardAutoscroll.set(newValue);
|
||||
localStorage.setItem('enable-standard-autoscroll', newValue.toString());
|
||||
}
|
||||
|
||||
toggleAcousticAutoscroll() {
|
||||
const newValue = !this.enableAcousticAutoscroll();
|
||||
this.enableAcousticAutoscroll.set(newValue);
|
||||
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
|
||||
toggleVisualAutoscroll() {
|
||||
const newValue = !this.enableVisualAutoscroll();
|
||||
this.enableVisualAutoscroll.set(newValue);
|
||||
localStorage.setItem('enable-visual-autoscroll', newValue.toString());
|
||||
}
|
||||
|
||||
async installPwa() {
|
||||
toggleKaraokePageScrollMode() {
|
||||
const newValue = !this.karaokePageScrollMode();
|
||||
this.karaokePageScrollMode.set(newValue);
|
||||
localStorage.setItem('karaoke-page-scroll-mode', newValue.toString());
|
||||
}
|
||||
|
||||
toggleLandscapeProjectionEnabled() {
|
||||
const newValue = !this.landscapeProjectionEnabled();
|
||||
this.landscapeProjectionEnabled.set(newValue);
|
||||
localStorage.setItem('landscape-projection-enabled', newValue.toString());
|
||||
}
|
||||
|
||||
toggleCarModeBlackScreen() {
|
||||
const newValue = !this.carModeBlackScreen();
|
||||
this.carModeBlackScreen.set(newValue);
|
||||
localStorage.setItem('car-mode-black-screen', newValue.toString());
|
||||
}
|
||||
|
||||
setChordNotationPreference(val: 'diesis' | 'bemolle') {
|
||||
this.chordNotationPreference.set(val);
|
||||
}
|
||||
|
||||
setUserUuid(uuid: string) {
|
||||
const trimmed = uuid.trim();
|
||||
if (trimmed) {
|
||||
this.userUuid.set(trimmed);
|
||||
localStorage.setItem('user-uuid', trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
setUserName(name: string) {
|
||||
const trimmed = name.trim();
|
||||
this.userName.set(trimmed);
|
||||
localStorage.setItem('user-name', trimmed);
|
||||
}
|
||||
|
||||
async installPwa(): Promise<string | undefined> {
|
||||
const promptEvent = this.deferredPrompt();
|
||||
if (!promptEvent) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
// Show the install prompt
|
||||
promptEvent.prompt();
|
||||
@@ -394,5 +549,6 @@ export class SettingsService {
|
||||
// We've used the prompt, and can't use it again, discard it
|
||||
this.deferredPrompt.set(null);
|
||||
this.showInstallButton.set(false);
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Injectable, signal, effect } from '@angular/core';
|
||||
export class ThemeService {
|
||||
public highContrast = signal<boolean>(true);
|
||||
|
||||
/** Whether the system prefers dark mode */
|
||||
private systemPrefersDark = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Load from localStorage
|
||||
const saved = localStorage.getItem('high-contrast');
|
||||
@@ -15,18 +18,49 @@ export class ThemeService {
|
||||
this.highContrast.set(true);
|
||||
}
|
||||
|
||||
// Effect to apply class to body
|
||||
// Detect system dark mode preference
|
||||
if (typeof window !== 'undefined') {
|
||||
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.systemPrefersDark.set(darkMq.matches);
|
||||
darkMq.addEventListener('change', (e) => {
|
||||
this.systemPrefersDark.set(e.matches);
|
||||
});
|
||||
}
|
||||
|
||||
// Effect to apply high-contrast class to body and html
|
||||
effect(() => {
|
||||
const isHigh = this.highContrast();
|
||||
if (typeof document !== 'undefined' && document.body) {
|
||||
if (typeof document !== 'undefined') {
|
||||
const root = document.documentElement;
|
||||
if (isHigh) {
|
||||
document.body.classList.add('high-contrast');
|
||||
root.classList.add('high-contrast');
|
||||
if (document.body) document.body.classList.add('high-contrast');
|
||||
} else {
|
||||
document.body.classList.remove('high-contrast');
|
||||
root.classList.remove('high-contrast');
|
||||
if (document.body) document.body.classList.remove('high-contrast');
|
||||
}
|
||||
}
|
||||
localStorage.setItem('high-contrast', isHigh.toString());
|
||||
});
|
||||
|
||||
// Effect to manage Ionic dark palette class
|
||||
// When high contrast is ON → NEVER apply dark palette (force light mode)
|
||||
// When high contrast is OFF and system prefers dark → apply dark palette
|
||||
effect(() => {
|
||||
const isHigh = this.highContrast();
|
||||
const systemDark = this.systemPrefersDark();
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const root = document.documentElement;
|
||||
if (!isHigh && systemDark) {
|
||||
root.classList.add('ion-palette-dark');
|
||||
if (document.body) document.body.classList.add('ion-palette-dark');
|
||||
} else {
|
||||
root.classList.remove('ion-palette-dark');
|
||||
if (document.body) document.body.classList.remove('ion-palette-dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleContrast() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MediaSessionService } from './media-session.service';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { ConnectivityService } from './connectivity.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import { PlaylistService } from './playlist.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -16,18 +17,13 @@ export class YoutubePlayerService {
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private connectivityService = inject(ConnectivityService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
private playlistService = inject(PlaylistService);
|
||||
|
||||
public isPlayerSupported = computed<boolean>(() => {
|
||||
// Check if offline
|
||||
if (!this.connectivityService.isOnline()) {
|
||||
return false;
|
||||
}
|
||||
// Check if iPhone/iPad/iPod
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const isIOS = /iphone|ipad|ipod/.test(userAgent);
|
||||
if (isIOS) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -54,8 +50,9 @@ export class YoutubePlayerService {
|
||||
// Auto-stop player when not supported (e.g. going offline)
|
||||
effect(() => {
|
||||
const supported = this.isPlayerSupported();
|
||||
if (!supported && this.currentCantoId()) {
|
||||
this.stop();
|
||||
if (!supported) {
|
||||
this.isPlaying.set(false);
|
||||
this.destroyPlayer();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -109,8 +106,8 @@ export class YoutubePlayerService {
|
||||
};
|
||||
}
|
||||
|
||||
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void) {
|
||||
if (this.currentCantoId() === cantoId && this.player) {
|
||||
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void, customYoutubeLink?: string) {
|
||||
if (this.currentCantoId() === cantoId && this.player && !customYoutubeLink) {
|
||||
this.onEndedCallback = onEnded || null;
|
||||
this.onErrorCallback = onError || null;
|
||||
|
||||
@@ -126,6 +123,10 @@ export class YoutubePlayerService {
|
||||
this.onEndedCallback = onEnded || null;
|
||||
this.onErrorCallback = onError || null;
|
||||
|
||||
let videoId: string | null = null;
|
||||
if (customYoutubeLink) {
|
||||
videoId = this.cantiService.getYoutubeId(customYoutubeLink);
|
||||
} else {
|
||||
let canto = this.cantiService.getCantoById(cantoId);
|
||||
if (!canto) {
|
||||
canto = this.myCantiService.myCanti().find(c => c.id === cantoId);
|
||||
@@ -133,23 +134,44 @@ export class YoutubePlayerService {
|
||||
if (!canto) {
|
||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
||||
}
|
||||
if (!canto) return;
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (canto) {
|
||||
videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
||||
}
|
||||
}
|
||||
|
||||
const videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
||||
if (!videoId) {
|
||||
if (onError) onError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(window as any).YT || !(window as any).YT.Player) {
|
||||
setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError), 200);
|
||||
setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError, customYoutubeLink), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse existing player if possible
|
||||
if (this.player && this.player.loadVideoById) {
|
||||
this.currentCantoId.set(cantoId);
|
||||
if (customYoutubeLink) {
|
||||
if ('mediaSession' in navigator && 'MediaMetadata' in window) {
|
||||
try {
|
||||
(navigator as any).mediaSession.metadata = new (window as any).MediaMetadata({
|
||||
title: 'Anteprima Canto',
|
||||
artist: 'Canti Cristiani',
|
||||
album: 'Canti Cristiani',
|
||||
artwork: [{ src: 'assets/icon/favicon.png', sizes: '512x512', type: 'image/png' }]
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
this.mediaSessionService.updateMetadata(cantoId);
|
||||
}
|
||||
this.player.loadVideoById({
|
||||
videoId: videoId,
|
||||
startSeconds: startTime
|
||||
@@ -160,7 +182,20 @@ export class YoutubePlayerService {
|
||||
|
||||
this.destroyPlayer();
|
||||
this.currentCantoId.set(cantoId);
|
||||
if (customYoutubeLink) {
|
||||
if ('mediaSession' in navigator && 'MediaMetadata' in window) {
|
||||
try {
|
||||
(navigator as any).mediaSession.metadata = new (window as any).MediaMetadata({
|
||||
title: 'Anteprima Canto',
|
||||
artist: 'Canti Cristiani',
|
||||
album: 'Canti Cristiani',
|
||||
artwork: [{ src: 'assets/icon/favicon.png', sizes: '512x512', type: 'image/png' }]
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
this.mediaSessionService.updateMetadata(cantoId);
|
||||
}
|
||||
|
||||
this.player = new (window as any).YT.Player('global-yt-player-container', {
|
||||
height: '1',
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.05.22.0110';
|
||||
export const VERSION = '2026.08.09.0826';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
(function(){/*
|
||||
|
||||
Copyright The Closure Library Authors.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
'use strict';function n(a){var b=0;return function(){return b<a.length?{done:!1,value:a[b++]}:{done:!0}}}var q="function"==typeof Object.defineProperties?Object.defineProperty:function(a,b,e){if(a==Array.prototype||a==Object.prototype)return a;a[b]=e.value;return a};
|
||||
function t(a){a=["object"==typeof globalThis&&globalThis,a,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var b=0;b<a.length;++b){var e=a[b];if(e&&e.Math==Math)return e}throw Error("Cannot find global object");}var u=t(this);function v(a,b){if(b)a:{var e=u;a=a.split(".");for(var f=0;f<a.length-1;f++){var h=a[f];if(!(h in e))break a;e=e[h]}a=a[a.length-1];f=e[a];b=b(f);b!=f&&null!=b&&q(e,a,{configurable:!0,writable:!0,value:b})}}
|
||||
v("Symbol",function(a){function b(l){if(this instanceof b)throw new TypeError("Symbol is not a constructor");return new e(f+(l||"")+"_"+h++,l)}function e(l,c){this.g=l;q(this,"description",{configurable:!0,writable:!0,value:c})}if(a)return a;e.prototype.toString=function(){return this.g};var f="jscomp_symbol_"+(1E9*Math.random()>>>0)+"_",h=0;return b});
|
||||
v("Symbol.iterator",function(a){if(a)return a;a=Symbol("Symbol.iterator");for(var b="Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array".split(" "),e=0;e<b.length;e++){var f=u[b[e]];"function"===typeof f&&"function"!=typeof f.prototype[a]&&q(f.prototype,a,{configurable:!0,writable:!0,value:function(){return w(n(this))}})}return a});function w(a){a={next:a};a[Symbol.iterator]=function(){return this};return a}
|
||||
function x(a){var b="undefined"!=typeof Symbol&&Symbol.iterator&&a[Symbol.iterator];return b?b.call(a):{next:n(a)}}function y(){this.i=!1;this.g=null;this.o=void 0;this.j=1;this.m=0;this.h=null}function z(a){if(a.i)throw new TypeError("Generator is already running");a.i=!0}y.prototype.l=function(a){this.o=a};function A(a,b){a.h={F:b,G:!0};a.j=a.m}y.prototype.return=function(a){this.h={return:a};this.j=this.m};function B(a){this.g=new y;this.h=a}
|
||||
function C(a,b){z(a.g);var e=a.g.g;if(e)return D(a,"return"in e?e["return"]:function(f){return{value:f,done:!0}},b,a.g.return);a.g.return(b);return H(a)}function D(a,b,e,f){try{var h=b.call(a.g.g,e);if(!(h instanceof Object))throw new TypeError("Iterator result "+h+" is not an object");if(!h.done)return a.g.i=!1,h;var l=h.value}catch(c){return a.g.g=null,A(a.g,c),H(a)}a.g.g=null;f.call(a.g,l);return H(a)}
|
||||
function H(a){for(;a.g.j;)try{var b=a.h(a.g);if(b)return a.g.i=!1,{value:b.value,done:!1}}catch(e){a.g.o=void 0,A(a.g,e)}a.g.i=!1;if(a.g.h){b=a.g.h;a.g.h=null;if(b.G)throw b.F;return{value:b.return,done:!0}}return{value:void 0,done:!0}}
|
||||
function I(a){this.next=function(b){z(a.g);a.g.g?b=D(a,a.g.g.next,b,a.g.l):(a.g.l(b),b=H(a));return b};this.throw=function(b){z(a.g);a.g.g?b=D(a,a.g.g["throw"],b,a.g.l):(A(a.g,b),b=H(a));return b};this.return=function(b){return C(a,b)};this[Symbol.iterator]=function(){return this}}function J(a){function b(f){return a.next(f)}function e(f){return a.throw(f)}return new Promise(function(f,h){function l(c){c.done?f(c.value):Promise.resolve(c.value).then(b,e).then(l,h)}l(a.next())})}
|
||||
v("Promise",function(a){function b(c){this.h=0;this.i=void 0;this.g=[];this.o=!1;var d=this.j();try{c(d.resolve,d.reject)}catch(g){d.reject(g)}}function e(){this.g=null}function f(c){return c instanceof b?c:new b(function(d){d(c)})}if(a)return a;e.prototype.h=function(c){if(null==this.g){this.g=[];var d=this;this.i(function(){d.l()})}this.g.push(c)};var h=u.setTimeout;e.prototype.i=function(c){h(c,0)};e.prototype.l=function(){for(;this.g&&this.g.length;){var c=this.g;this.g=[];for(var d=0;d<c.length;++d){var g=
|
||||
c[d];c[d]=null;try{g()}catch(k){this.j(k)}}}this.g=null};e.prototype.j=function(c){this.i(function(){throw c;})};b.prototype.j=function(){function c(k){return function(m){g||(g=!0,k.call(d,m))}}var d=this,g=!1;return{resolve:c(this.A),reject:c(this.l)}};b.prototype.A=function(c){if(c===this)this.l(new TypeError("A Promise cannot resolve to itself"));else if(c instanceof b)this.C(c);else{a:switch(typeof c){case "object":var d=null!=c;break a;case "function":d=!0;break a;default:d=!1}d?this.v(c):this.m(c)}};
|
||||
b.prototype.v=function(c){var d=void 0;try{d=c.then}catch(g){this.l(g);return}"function"==typeof d?this.D(d,c):this.m(c)};b.prototype.l=function(c){this.u(2,c)};b.prototype.m=function(c){this.u(1,c)};b.prototype.u=function(c,d){if(0!=this.h)throw Error("Cannot settle("+c+", "+d+"): Promise already settled in state"+this.h);this.h=c;this.i=d;2===this.h&&this.B();this.H()};b.prototype.B=function(){var c=this;h(function(){if(c.I()){var d=u.console;"undefined"!==typeof d&&d.error(c.i)}},1)};b.prototype.I=
|
||||
function(){if(this.o)return!1;var c=u.CustomEvent,d=u.Event,g=u.dispatchEvent;if("undefined"===typeof g)return!0;"function"===typeof c?c=new c("unhandledrejection",{cancelable:!0}):"function"===typeof d?c=new d("unhandledrejection",{cancelable:!0}):(c=u.document.createEvent("CustomEvent"),c.initCustomEvent("unhandledrejection",!1,!0,c));c.promise=this;c.reason=this.i;return g(c)};b.prototype.H=function(){if(null!=this.g){for(var c=0;c<this.g.length;++c)l.h(this.g[c]);this.g=null}};var l=new e;b.prototype.C=
|
||||
function(c){var d=this.j();c.s(d.resolve,d.reject)};b.prototype.D=function(c,d){var g=this.j();try{c.call(d,g.resolve,g.reject)}catch(k){g.reject(k)}};b.prototype.then=function(c,d){function g(p,r){return"function"==typeof p?function(E){try{k(p(E))}catch(F){m(F)}}:r}var k,m,G=new b(function(p,r){k=p;m=r});this.s(g(c,k),g(d,m));return G};b.prototype.catch=function(c){return this.then(void 0,c)};b.prototype.s=function(c,d){function g(){switch(k.h){case 1:c(k.i);break;case 2:d(k.i);break;default:throw Error("Unexpected state: "+
|
||||
k.h);}}var k=this;null==this.g?l.h(g):this.g.push(g);this.o=!0};b.resolve=f;b.reject=function(c){return new b(function(d,g){g(c)})};b.race=function(c){return new b(function(d,g){for(var k=x(c),m=k.next();!m.done;m=k.next())f(m.value).s(d,g)})};b.all=function(c){var d=x(c),g=d.next();return g.done?f([]):new b(function(k,m){function G(E){return function(F){p[E]=F;r--;0==r&&k(p)}}var p=[],r=0;do p.push(void 0),r++,f(g.value).s(G(p.length-1),m),g=d.next();while(!g.done)})};return b});
|
||||
var K="function"==typeof Object.assign?Object.assign:function(a,b){for(var e=1;e<arguments.length;e++){var f=arguments[e];if(f)for(var h in f)Object.prototype.hasOwnProperty.call(f,h)&&(a[h]=f[h])}return a};v("Object.assign",function(a){return a||K});var L=this||self;var M={facingMode:"user",width:640,height:480};function N(a,b){this.video=a;this.i=0;this.h=Object.assign(Object.assign({},M),b)}N.prototype.stop=function(){var a=this,b,e,f,h;return J(new I(new B(function(l){if(a.g){b=a.g.getTracks();e=x(b);for(f=e.next();!f.done;f=e.next())h=f.value,h.stop();a.g=void 0}l.j=0})))};
|
||||
N.prototype.start=function(){var a=this,b;return J(new I(new B(function(e){navigator.mediaDevices&&navigator.mediaDevices.getUserMedia||alert("No navigator.mediaDevices.getUserMedia exists.");b=a.h;return e.return(navigator.mediaDevices.getUserMedia({video:{facingMode:b.facingMode,width:b.width,height:b.height}}).then(function(f){O(a,f)}).catch(function(f){var h="Failed to acquire camera feed: "+f;console.error(h);alert(h);throw f;}))})))};
|
||||
function P(a){window.requestAnimationFrame(function(){Q(a)})}function O(a,b){a.g=b;a.video.srcObject=b;a.video.onloadedmetadata=function(){a.video.play();P(a)}}function Q(a){var b=null;a.video.paused||a.video.currentTime===a.i||(a.i=a.video.currentTime,b=a.h.onFrame());b?b.then(function(){P(a)}):P(a)}var R=["Camera"],S=L;R[0]in S||"undefined"==typeof S.execScript||S.execScript("var "+R[0]);
|
||||
for(var T;R.length&&(T=R.shift());)R.length||void 0===N?S[T]&&S[T]!==Object.prototype[T]?S=S[T]:S=S[T]={}:S[T]=N;}).call(this);
|
||||
Binary file not shown.
@@ -0,0 +1,131 @@
|
||||
(function(){/*
|
||||
|
||||
Copyright The Closure Library Authors.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
'use strict';var v;function aa(a){var b=0;return function(){return b<a.length?{done:!1,value:a[b++]}:{done:!0}}}var ba="function"==typeof Object.defineProperties?Object.defineProperty:function(a,b,c){if(a==Array.prototype||a==Object.prototype)return a;a[b]=c.value;return a};
|
||||
function ca(a){a=["object"==typeof globalThis&&globalThis,a,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var b=0;b<a.length;++b){var c=a[b];if(c&&c.Math==Math)return c}throw Error("Cannot find global object");}var G=ca(this);function J(a,b){if(b)a:{var c=G;a=a.split(".");for(var d=0;d<a.length-1;d++){var e=a[d];if(!(e in c))break a;c=c[e]}a=a[a.length-1];d=c[a];b=b(d);b!=d&&null!=b&&ba(c,a,{configurable:!0,writable:!0,value:b})}}
|
||||
J("Symbol",function(a){function b(g){if(this instanceof b)throw new TypeError("Symbol is not a constructor");return new c(d+(g||"")+"_"+e++,g)}function c(g,f){this.g=g;ba(this,"description",{configurable:!0,writable:!0,value:f})}if(a)return a;c.prototype.toString=function(){return this.g};var d="jscomp_symbol_"+(1E9*Math.random()>>>0)+"_",e=0;return b});
|
||||
J("Symbol.iterator",function(a){if(a)return a;a=Symbol("Symbol.iterator");for(var b="Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array".split(" "),c=0;c<b.length;c++){var d=G[b[c]];"function"===typeof d&&"function"!=typeof d.prototype[a]&&ba(d.prototype,a,{configurable:!0,writable:!0,value:function(){return da(aa(this))}})}return a});function da(a){a={next:a};a[Symbol.iterator]=function(){return this};return a}
|
||||
function K(a){var b="undefined"!=typeof Symbol&&Symbol.iterator&&a[Symbol.iterator];return b?b.call(a):{next:aa(a)}}function L(a){if(!(a instanceof Array)){a=K(a);for(var b,c=[];!(b=a.next()).done;)c.push(b.value);a=c}return a}var ea="function"==typeof Object.create?Object.create:function(a){function b(){}b.prototype=a;return new b},fa;
|
||||
if("function"==typeof Object.setPrototypeOf)fa=Object.setPrototypeOf;else{var ha;a:{var ia={a:!0},ja={};try{ja.__proto__=ia;ha=ja.a;break a}catch(a){}ha=!1}fa=ha?function(a,b){a.__proto__=b;if(a.__proto__!==b)throw new TypeError(a+" is not extensible");return a}:null}var ka=fa;
|
||||
function M(a,b){a.prototype=ea(b.prototype);a.prototype.constructor=a;if(ka)ka(a,b);else for(var c in b)if("prototype"!=c)if(Object.defineProperties){var d=Object.getOwnPropertyDescriptor(b,c);d&&Object.defineProperty(a,c,d)}else a[c]=b[c];a.ea=b.prototype}function ma(){this.l=!1;this.i=null;this.h=void 0;this.g=1;this.s=this.m=0;this.j=null}function na(a){if(a.l)throw new TypeError("Generator is already running");a.l=!0}ma.prototype.o=function(a){this.h=a};
|
||||
function oa(a,b){a.j={U:b,V:!0};a.g=a.m||a.s}ma.prototype.return=function(a){this.j={return:a};this.g=this.s};function N(a,b,c){a.g=c;return{value:b}}function pa(a){this.g=new ma;this.h=a}function qa(a,b){na(a.g);var c=a.g.i;if(c)return ra(a,"return"in c?c["return"]:function(d){return{value:d,done:!0}},b,a.g.return);a.g.return(b);return sa(a)}
|
||||
function ra(a,b,c,d){try{var e=b.call(a.g.i,c);if(!(e instanceof Object))throw new TypeError("Iterator result "+e+" is not an object");if(!e.done)return a.g.l=!1,e;var g=e.value}catch(f){return a.g.i=null,oa(a.g,f),sa(a)}a.g.i=null;d.call(a.g,g);return sa(a)}function sa(a){for(;a.g.g;)try{var b=a.h(a.g);if(b)return a.g.l=!1,{value:b.value,done:!1}}catch(c){a.g.h=void 0,oa(a.g,c)}a.g.l=!1;if(a.g.j){b=a.g.j;a.g.j=null;if(b.V)throw b.U;return{value:b.return,done:!0}}return{value:void 0,done:!0}}
|
||||
function ta(a){this.next=function(b){na(a.g);a.g.i?b=ra(a,a.g.i.next,b,a.g.o):(a.g.o(b),b=sa(a));return b};this.throw=function(b){na(a.g);a.g.i?b=ra(a,a.g.i["throw"],b,a.g.o):(oa(a.g,b),b=sa(a));return b};this.return=function(b){return qa(a,b)};this[Symbol.iterator]=function(){return this}}function O(a,b){b=new ta(new pa(b));ka&&a.prototype&&ka(b,a.prototype);return b}
|
||||
function ua(a,b){a instanceof String&&(a+="");var c=0,d=!1,e={next:function(){if(!d&&c<a.length){var g=c++;return{value:b(g,a[g]),done:!1}}d=!0;return{done:!0,value:void 0}}};e[Symbol.iterator]=function(){return e};return e}var va="function"==typeof Object.assign?Object.assign:function(a,b){for(var c=1;c<arguments.length;c++){var d=arguments[c];if(d)for(var e in d)Object.prototype.hasOwnProperty.call(d,e)&&(a[e]=d[e])}return a};J("Object.assign",function(a){return a||va});
|
||||
J("Promise",function(a){function b(f){this.h=0;this.i=void 0;this.g=[];this.o=!1;var h=this.j();try{f(h.resolve,h.reject)}catch(k){h.reject(k)}}function c(){this.g=null}function d(f){return f instanceof b?f:new b(function(h){h(f)})}if(a)return a;c.prototype.h=function(f){if(null==this.g){this.g=[];var h=this;this.i(function(){h.l()})}this.g.push(f)};var e=G.setTimeout;c.prototype.i=function(f){e(f,0)};c.prototype.l=function(){for(;this.g&&this.g.length;){var f=this.g;this.g=[];for(var h=0;h<f.length;++h){var k=
|
||||
f[h];f[h]=null;try{k()}catch(l){this.j(l)}}}this.g=null};c.prototype.j=function(f){this.i(function(){throw f;})};b.prototype.j=function(){function f(l){return function(n){k||(k=!0,l.call(h,n))}}var h=this,k=!1;return{resolve:f(this.C),reject:f(this.l)}};b.prototype.C=function(f){if(f===this)this.l(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof b)this.F(f);else{a:switch(typeof f){case "object":var h=null!=f;break a;case "function":h=!0;break a;default:h=!1}h?this.u(f):this.m(f)}};
|
||||
b.prototype.u=function(f){var h=void 0;try{h=f.then}catch(k){this.l(k);return}"function"==typeof h?this.G(h,f):this.m(f)};b.prototype.l=function(f){this.s(2,f)};b.prototype.m=function(f){this.s(1,f)};b.prototype.s=function(f,h){if(0!=this.h)throw Error("Cannot settle("+f+", "+h+"): Promise already settled in state"+this.h);this.h=f;this.i=h;2===this.h&&this.D();this.A()};b.prototype.D=function(){var f=this;e(function(){if(f.B()){var h=G.console;"undefined"!==typeof h&&h.error(f.i)}},1)};b.prototype.B=
|
||||
function(){if(this.o)return!1;var f=G.CustomEvent,h=G.Event,k=G.dispatchEvent;if("undefined"===typeof k)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):"function"===typeof h?f=new h("unhandledrejection",{cancelable:!0}):(f=G.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.i;return k(f)};b.prototype.A=function(){if(null!=this.g){for(var f=0;f<this.g.length;++f)g.h(this.g[f]);this.g=null}};var g=new c;b.prototype.F=
|
||||
function(f){var h=this.j();f.J(h.resolve,h.reject)};b.prototype.G=function(f,h){var k=this.j();try{f.call(h,k.resolve,k.reject)}catch(l){k.reject(l)}};b.prototype.then=function(f,h){function k(w,r){return"function"==typeof w?function(y){try{l(w(y))}catch(m){n(m)}}:r}var l,n,u=new b(function(w,r){l=w;n=r});this.J(k(f,l),k(h,n));return u};b.prototype.catch=function(f){return this.then(void 0,f)};b.prototype.J=function(f,h){function k(){switch(l.h){case 1:f(l.i);break;case 2:h(l.i);break;default:throw Error("Unexpected state: "+
|
||||
l.h);}}var l=this;null==this.g?g.h(k):this.g.push(k);this.o=!0};b.resolve=d;b.reject=function(f){return new b(function(h,k){k(f)})};b.race=function(f){return new b(function(h,k){for(var l=K(f),n=l.next();!n.done;n=l.next())d(n.value).J(h,k)})};b.all=function(f){var h=K(f),k=h.next();return k.done?d([]):new b(function(l,n){function u(y){return function(m){w[y]=m;r--;0==r&&l(w)}}var w=[],r=0;do w.push(void 0),r++,d(k.value).J(u(w.length-1),n),k=h.next();while(!k.done)})};return b});
|
||||
J("Object.is",function(a){return a?a:function(b,c){return b===c?0!==b||1/b===1/c:b!==b&&c!==c}});J("Array.prototype.includes",function(a){return a?a:function(b,c){var d=this;d instanceof String&&(d=String(d));var e=d.length;c=c||0;for(0>c&&(c=Math.max(c+e,0));c<e;c++){var g=d[c];if(g===b||Object.is(g,b))return!0}return!1}});
|
||||
J("String.prototype.includes",function(a){return a?a:function(b,c){if(null==this)throw new TypeError("The 'this' value for String.prototype.includes must not be null or undefined");if(b instanceof RegExp)throw new TypeError("First argument to String.prototype.includes must not be a regular expression");return-1!==this.indexOf(b,c||0)}});J("Array.prototype.keys",function(a){return a?a:function(){return ua(this,function(b){return b})}});var wa=this||self;
|
||||
function P(a,b){a=a.split(".");var c=wa;a[0]in c||"undefined"==typeof c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b};function xa(a,b){b=String.fromCharCode.apply(null,b);return null==a?b:a+b}var ya,za="undefined"!==typeof TextDecoder,Aa,Ba="undefined"!==typeof TextEncoder;
|
||||
function Ca(a){if(Ba)a=(Aa||(Aa=new TextEncoder)).encode(a);else{var b=void 0;b=void 0===b?!1:b;for(var c=0,d=new Uint8Array(3*a.length),e=0;e<a.length;e++){var g=a.charCodeAt(e);if(128>g)d[c++]=g;else{if(2048>g)d[c++]=g>>6|192;else{if(55296<=g&&57343>=g){if(56319>=g&&e<a.length){var f=a.charCodeAt(++e);if(56320<=f&&57343>=f){g=1024*(g-55296)+f-56320+65536;d[c++]=g>>18|240;d[c++]=g>>12&63|128;d[c++]=g>>6&63|128;d[c++]=g&63|128;continue}else e--}if(b)throw Error("Found an unpaired surrogate");g=65533}d[c++]=
|
||||
g>>12|224;d[c++]=g>>6&63|128}d[c++]=g&63|128}}a=d.subarray(0,c)}return a};var Da={},Ea=null;function Fa(a,b){void 0===b&&(b=0);Ga();b=Da[b];for(var c=Array(Math.floor(a.length/3)),d=b[64]||"",e=0,g=0;e<a.length-2;e+=3){var f=a[e],h=a[e+1],k=a[e+2],l=b[f>>2];f=b[(f&3)<<4|h>>4];h=b[(h&15)<<2|k>>6];k=b[k&63];c[g++]=l+f+h+k}l=0;k=d;switch(a.length-e){case 2:l=a[e+1],k=b[(l&15)<<2]||d;case 1:a=a[e],c[g]=b[a>>2]+b[(a&3)<<4|l>>4]+k+d}return c.join("")}
|
||||
function Ha(a){var b=a.length,c=3*b/4;c%3?c=Math.floor(c):-1!="=.".indexOf(a[b-1])&&(c=-1!="=.".indexOf(a[b-2])?c-2:c-1);var d=new Uint8Array(c),e=0;Ia(a,function(g){d[e++]=g});return d.subarray(0,e)}
|
||||
function Ia(a,b){function c(k){for(;d<a.length;){var l=a.charAt(d++),n=Ea[l];if(null!=n)return n;if(!/^[\s\xa0]*$/.test(l))throw Error("Unknown base64 encoding at char: "+l);}return k}Ga();for(var d=0;;){var e=c(-1),g=c(0),f=c(64),h=c(64);if(64===h&&-1===e)break;b(e<<2|g>>4);64!=f&&(b(g<<4&240|f>>2),64!=h&&b(f<<6&192|h))}}
|
||||
function Ga(){if(!Ea){Ea={};for(var a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(""),b=["+/=","+/","-_=","-_.","-_"],c=0;5>c;c++){var d=a.concat(b[c].split(""));Da[c]=d;for(var e=0;e<d.length;e++){var g=d[e];void 0===Ea[g]&&(Ea[g]=e)}}}};var Ja="function"===typeof Uint8Array.prototype.slice,Ka;function La(a,b,c){return b===c?Ka||(Ka=new Uint8Array(0)):Ja?a.slice(b,c):new Uint8Array(a.subarray(b,c))}var Q=0,R=0;function Ma(a,b){b=void 0===b?{}:b;b=void 0===b.v?!1:b.v;this.h=null;this.g=this.j=this.l=0;this.m=!1;this.v=b;a&&Na(this,a)}function Na(a,b){b=b.constructor===Uint8Array?b:b.constructor===ArrayBuffer?new Uint8Array(b):b.constructor===Array?new Uint8Array(b):b.constructor===String?Ha(b):b instanceof Uint8Array?new Uint8Array(b.buffer,b.byteOffset,b.byteLength):new Uint8Array(0);a.h=b;a.l=0;a.j=a.h.length;a.g=a.l}Ma.prototype.reset=function(){this.g=this.l};
|
||||
function Oa(a){for(var b=128,c=0,d=0,e=0;4>e&&128<=b;e++)b=a.h[a.g++],c|=(b&127)<<7*e;128<=b&&(b=a.h[a.g++],c|=(b&127)<<28,d|=(b&127)>>4);if(128<=b)for(e=0;5>e&&128<=b;e++)b=a.h[a.g++],d|=(b&127)<<7*e+3;if(128>b){a=c>>>0;b=d>>>0;if(d=b&2147483648)a=~a+1>>>0,b=~b>>>0,0==a&&(b=b+1>>>0);a=4294967296*b+(a>>>0);return d?-a:a}a.m=!0}
|
||||
Ma.prototype.i=function(){var a=this.h,b=a[this.g],c=b&127;if(128>b)return this.g+=1,c;b=a[this.g+1];c|=(b&127)<<7;if(128>b)return this.g+=2,c;b=a[this.g+2];c|=(b&127)<<14;if(128>b)return this.g+=3,c;b=a[this.g+3];c|=(b&127)<<21;if(128>b)return this.g+=4,c;b=a[this.g+4];c|=(b&15)<<28;if(128>b)return this.g+=5,c>>>0;this.g+=5;128<=a[this.g++]&&128<=a[this.g++]&&128<=a[this.g++]&&128<=a[this.g++]&&this.g++;return c};
|
||||
Ma.prototype.o=function(){var a=this.h[this.g],b=this.h[this.g+1];var c=this.h[this.g+2];var d=this.h[this.g+3];this.g+=4;c=(a<<0|b<<8|c<<16|d<<24)>>>0;a=2*(c>>31)+1;b=c>>>23&255;c&=8388607;return 255==b?c?NaN:Infinity*a:0==b?a*Math.pow(2,-149)*c:a*Math.pow(2,b-150)*(c+Math.pow(2,23))};var Pa=[];function Qa(){this.g=new Uint8Array(64);this.h=0}Qa.prototype.push=function(a){if(!(this.h+1<this.g.length)){var b=this.g;this.g=new Uint8Array(Math.ceil(1+2*this.g.length));this.g.set(b)}this.g[this.h++]=a};Qa.prototype.length=function(){return this.h};Qa.prototype.end=function(){var a=this.g,b=this.h;this.h=0;return La(a,0,b)};function Ra(a,b){for(;127<b;)a.push(b&127|128),b>>>=7;a.push(b)};function Sa(a){var b={},c=void 0===b.N?!1:b.N;this.o={v:void 0===b.v?!1:b.v};this.N=c;b=this.o;Pa.length?(c=Pa.pop(),b&&(c.v=b.v),a&&Na(c,a),a=c):a=new Ma(a,b);this.g=a;this.m=this.g.g;this.h=this.i=this.l=-1;this.j=!1}Sa.prototype.reset=function(){this.g.reset();this.h=this.l=-1};function S(a){var b=a.g;(b=b.g==b.j)||(b=a.j)||(b=a.g,b=b.m||0>b.g||b.g>b.j);if(b)return!1;a.m=a.g.g;b=a.g.i();var c=b&7;if(0!=c&&5!=c&&1!=c&&2!=c&&3!=c&&4!=c)return a.j=!0,!1;a.i=b;a.l=b>>>3;a.h=c;return!0}
|
||||
function Ta(a){switch(a.h){case 0:if(0!=a.h)Ta(a);else{for(a=a.g;a.h[a.g]&128;)a.g++;a.g++}break;case 1:1!=a.h?Ta(a):(a=a.g,a.g+=8);break;case 2:if(2!=a.h)Ta(a);else{var b=a.g.i();a=a.g;a.g+=b}break;case 5:5!=a.h?Ta(a):(a=a.g,a.g+=4);break;case 3:b=a.l;do{if(!S(a)){a.j=!0;break}if(4==a.h){a.l!=b&&(a.j=!0);break}Ta(a)}while(1);break;default:a.j=!0}}
|
||||
function Ua(a,b,c){var d=a.g.j,e=a.g.i(),g=a.g.g+e;a.g.j=g;c(b,a);c=g-a.g.g;if(0!==c)throw Error("Message parsing ended unexpectedly. Expected to read "+e+" bytes, instead read "+(e-c)+" bytes, either the data ended unexpectedly or the message misreported its own length");a.g.g=g;a.g.j=d;return b}function T(a){return a.g.o()}
|
||||
function Va(a){var b=a.g.i();a=a.g;var c=a.g;a.g+=b;a=a.h;var d;if(za)(d=ya)||(d=ya=new TextDecoder("utf-8",{fatal:!1})),d=d.decode(a.subarray(c,c+b));else{b=c+b;for(var e=[],g=null,f,h,k;c<b;)f=a[c++],128>f?e.push(f):224>f?c>=b?e.push(65533):(h=a[c++],194>f||128!==(h&192)?(c--,e.push(65533)):e.push((f&31)<<6|h&63)):240>f?c>=b-1?e.push(65533):(h=a[c++],128!==(h&192)||224===f&&160>h||237===f&&160<=h||128!==((d=a[c++])&192)?(c--,e.push(65533)):e.push((f&15)<<12|(h&63)<<6|d&63)):244>=f?c>=b-2?e.push(65533):
|
||||
(h=a[c++],128!==(h&192)||0!==(f<<28)+(h-144)>>30||128!==((d=a[c++])&192)||128!==((k=a[c++])&192)?(c--,e.push(65533)):(f=(f&7)<<18|(h&63)<<12|(d&63)<<6|k&63,f-=65536,e.push((f>>10&1023)+55296,(f&1023)+56320))):e.push(65533),8192<=e.length&&(g=xa(g,e),e.length=0);d=xa(g,e)}return d}function Wa(a,b,c){var d=a.g.i();for(d=a.g.g+d;a.g.g<d;)c.push(b.call(a.g))}function Xa(a,b){2==a.h?Wa(a,Ma.prototype.o,b):b.push(T(a))};function Ya(){this.h=[];this.i=0;this.g=new Qa}function Za(a,b){0!==b.length&&(a.h.push(b),a.i+=b.length)}function $a(a){var b=a.i+a.g.length();if(0===b)return new Uint8Array(0);b=new Uint8Array(b);for(var c=a.h,d=c.length,e=0,g=0;g<d;g++){var f=c[g];0!==f.length&&(b.set(f,e),e+=f.length)}c=a.g;d=c.h;0!==d&&(b.set(c.g.subarray(0,d),e),c.h=0);a.h=[b];return b}
|
||||
function U(a,b,c){if(null!=c){Ra(a.g,8*b+5);a=a.g;var d=c;d=(c=0>d?1:0)?-d:d;0===d?0<1/d?Q=R=0:(R=0,Q=2147483648):isNaN(d)?(R=0,Q=2147483647):3.4028234663852886E38<d?(R=0,Q=(c<<31|2139095040)>>>0):1.1754943508222875E-38>d?(d=Math.round(d/Math.pow(2,-149)),R=0,Q=(c<<31|d)>>>0):(b=Math.floor(Math.log(d)/Math.LN2),d*=Math.pow(2,-b),d=Math.round(8388608*d),16777216<=d&&++b,R=0,Q=(c<<31|b+127<<23|d&8388607)>>>0);c=Q;a.push(c>>>0&255);a.push(c>>>8&255);a.push(c>>>16&255);a.push(c>>>24&255)}};var ab="function"===typeof Uint8Array;function bb(a,b,c){if(null!=a)return"object"===typeof a?ab&&a instanceof Uint8Array?c(a):cb(a,b,c):b(a)}function cb(a,b,c){if(Array.isArray(a)){for(var d=Array(a.length),e=0;e<a.length;e++)d[e]=bb(a[e],b,c);Array.isArray(a)&&a.W&&db(d);return d}d={};for(e in a)d[e]=bb(a[e],b,c);return d}function eb(a){return"number"===typeof a?isFinite(a)?a:String(a):a}var fb={W:{value:!0,configurable:!0}};
|
||||
function db(a){Array.isArray(a)&&!Object.isFrozen(a)&&Object.defineProperties(a,fb);return a};var gb;function V(a,b,c){var d=gb;gb=null;a||(a=d);d=this.constructor.ca;a||(a=d?[d]:[]);this.j=d?0:-1;this.m=this.g=null;this.h=a;a:{d=this.h.length;a=d-1;if(d&&(d=this.h[a],!(null===d||"object"!=typeof d||Array.isArray(d)||ab&&d instanceof Uint8Array))){this.l=a-this.j;this.i=d;break a}void 0!==b&&-1<b?(this.l=Math.max(b,a+1-this.j),this.i=null):this.l=Number.MAX_VALUE}if(c)for(b=0;b<c.length;b++)a=c[b],a<this.l?(a+=this.j,(d=this.h[a])?db(d):this.h[a]=hb):(ib(this),(d=this.i[a])?db(d):this.i[a]=hb)}
|
||||
var hb=Object.freeze(db([]));function ib(a){var b=a.l+a.j;a.h[b]||(a.i=a.h[b]={})}function W(a,b,c){return-1===b?null:(void 0===c?0:c)||b>=a.l?a.i?a.i[b]:void 0:a.h[b+a.j]}function jb(a,b){var c=void 0===c?!1:c;var d=W(a,b,c);null==d&&(d=hb);d===hb&&(d=db([]),X(a,b,d,c));return d}function kb(a){var b=jb(a,3);a.m||(a.m={});if(!a.m[3]){for(var c=0;c<b.length;c++)b[c]=+b[c];a.m[3]=!0}return b}function lb(a,b,c){a=W(a,b);return null==a?c:a}
|
||||
function Y(a,b,c){a=W(a,b);a=null==a?a:+a;return null==a?void 0===c?0:c:a}function X(a,b,c,d){(void 0===d?0:d)||b>=a.l?(ib(a),a.i[b]=c):a.h[b+a.j]=c}function mb(a,b,c){if(-1===c)return null;a.g||(a.g={});if(!a.g[c]){var d=W(a,c,!1);d&&(a.g[c]=new b(d))}return a.g[c]}function nb(a,b){a.g||(a.g={});var c=a.g[1];if(!c){var d=jb(a,1);c=[];for(var e=0;e<d.length;e++)c[e]=new b(d[e]);a.g[1]=c}return c}function ob(a,b,c){var d=void 0===d?!1:d;a.g||(a.g={});var e=c?pb(c,!1):c;a.g[b]=c;X(a,b,e,d)}
|
||||
function qb(a,b,c,d){var e=nb(a,c);b=b?b:new c;a=jb(a,1);void 0!=d?(e.splice(d,0,b),a.splice(d,0,pb(b,!1))):(e.push(b),a.push(pb(b,!1)))}V.prototype.toJSON=function(){var a=pb(this,!1);return cb(a,eb,Fa)};function pb(a,b){if(a.g)for(var c in a.g){var d=a.g[c];if(Array.isArray(d))for(var e=0;e<d.length;e++)d[e]&&pb(d[e],b);else d&&pb(d,b)}return a.h}V.prototype.toString=function(){return pb(this,!1).toString()};function rb(a,b){if(a=a.o){Za(b,b.g.end());for(var c=0;c<a.length;c++)Za(b,a[c])}}function sb(a,b){if(4==b.h)return!1;var c=b.m;Ta(b);b.N||(b=La(b.g.h,c,b.g.g),(c=a.o)?c.push(b):a.o=[b]);return!0};function tb(a){V.call(this,a,-1,ub)}M(tb,V);tb.prototype.getRows=function(){return W(this,1)};tb.prototype.getCols=function(){return W(this,2)};tb.prototype.getPackedDataList=function(){return kb(this)};tb.prototype.getLayout=function(){return lb(this,4,0)};function vb(a,b){for(;S(b);)switch(b.i){case 8:var c=b.g.i();X(a,1,c);break;case 16:c=b.g.i();X(a,2,c);break;case 29:case 26:Xa(b,a.getPackedDataList());break;case 32:c=Oa(b.g);X(a,4,c);break;default:if(!sb(a,b))return a}return a}var ub=[3];function Z(a,b){var c=void 0;return new (c||(c=Promise))(function(d,e){function g(k){try{h(b.next(k))}catch(l){e(l)}}function f(k){try{h(b["throw"](k))}catch(l){e(l)}}function h(k){k.done?d(k.value):(new c(function(l){l(k.value)})).then(g,f)}h((b=b.apply(a,void 0)).next())})};function wb(a){V.call(this,a)}M(wb,V);function xb(a,b){for(;S(b);)switch(b.i){case 8:var c=b.g.i();X(a,1,c);break;case 21:c=T(b);X(a,2,c);break;case 26:c=Va(b);X(a,3,c);break;case 34:c=Va(b);X(a,4,c);break;default:if(!sb(a,b))return a}return a};function yb(a){V.call(this,a,-1,zb)}M(yb,V);yb.prototype.addClassification=function(a,b){qb(this,a,wb,b);return this};var zb=[1];function Ab(a){V.call(this,a)}M(Ab,V);function Bb(a,b){for(;S(b);)switch(b.i){case 13:var c=T(b);X(a,1,c);break;case 21:c=T(b);X(a,2,c);break;case 29:c=T(b);X(a,3,c);break;case 37:c=T(b);X(a,4,c);break;case 45:c=T(b);X(a,5,c);break;default:if(!sb(a,b))return a}return a};function Cb(a){V.call(this,a,-1,Db)}M(Cb,V);function Eb(a){a:{var b=new Cb;for(a=new Sa(a);S(a);)switch(a.i){case 10:var c=Ua(a,new Ab,Bb);qb(b,c,Ab,void 0);break;default:if(!sb(b,a))break a}}return b}var Db=[1];function Fb(a){V.call(this,a)}M(Fb,V);function Gb(a){V.call(this,a,-1,Hb)}M(Gb,V);Gb.prototype.getVertexType=function(){return lb(this,1,0)};Gb.prototype.getPrimitiveType=function(){return lb(this,2,0)};Gb.prototype.getVertexBufferList=function(){return kb(this)};Gb.prototype.getIndexBufferList=function(){return jb(this,4)};
|
||||
function Ib(a,b){for(;S(b);)switch(b.i){case 8:var c=Oa(b.g);X(a,1,c);break;case 16:c=Oa(b.g);X(a,2,c);break;case 29:case 26:Xa(b,a.getVertexBufferList());break;case 32:case 34:c=b;var d=a.getIndexBufferList();2==c.h?Wa(c,Ma.prototype.i,d):d.push(c.g.i());break;default:if(!sb(a,b))return a}return a}var Hb=[3,4];function Jb(a){V.call(this,a)}M(Jb,V);Jb.prototype.getMesh=function(){return mb(this,Gb,1)};Jb.prototype.getPoseTransformMatrix=function(){return mb(this,tb,2)};function Kb(a){a:{var b=new Jb;for(a=new Sa(a);S(a);)switch(a.i){case 10:var c=Ua(a,new Gb,Ib);ob(b,1,c);break;case 18:c=Ua(a,new tb,vb);ob(b,2,c);break;default:if(!sb(b,a))break a}}return b};function Lb(a,b,c){c=a.createShader(0===c?a.VERTEX_SHADER:a.FRAGMENT_SHADER);a.shaderSource(c,b);a.compileShader(c);if(!a.getShaderParameter(c,a.COMPILE_STATUS))throw Error("Could not compile WebGL shader.\n\n"+a.getShaderInfoLog(c));return c};function Mb(a){return nb(a,wb).map(function(b){return{index:lb(b,1,0),Y:Y(b,2),label:null!=W(b,3)?lb(b,3,""):void 0,displayName:null!=W(b,4)?lb(b,4,""):void 0}})};function Nb(a){return{x:Y(a,1),y:Y(a,2),z:Y(a,3),visibility:null!=W(a,4)?Y(a,4):void 0}};function Ob(a,b){this.h=a;this.g=b;this.l=0}
|
||||
function Pb(a,b,c){Qb(a,b);if("function"===typeof a.g.canvas.transferToImageBitmap)return Promise.resolve(a.g.canvas.transferToImageBitmap());if(c)return Promise.resolve(a.g.canvas);if("function"===typeof createImageBitmap)return createImageBitmap(a.g.canvas);void 0===a.i&&(a.i=document.createElement("canvas"));return new Promise(function(d){a.i.height=a.g.canvas.height;a.i.width=a.g.canvas.width;a.i.getContext("2d",{}).drawImage(a.g.canvas,0,0,a.g.canvas.width,a.g.canvas.height);d(a.i)})}
|
||||
function Qb(a,b){var c=a.g;if(void 0===a.m){var d=Lb(c,"\n attribute vec2 aVertex;\n attribute vec2 aTex;\n varying vec2 vTex;\n void main(void) {\n gl_Position = vec4(aVertex, 0.0, 1.0);\n vTex = aTex;\n }",0),e=Lb(c,"\n precision mediump float;\n varying vec2 vTex;\n uniform sampler2D sampler0;\n void main(){\n gl_FragColor = texture2D(sampler0, vTex);\n }",1),g=c.createProgram();c.attachShader(g,d);c.attachShader(g,e);c.linkProgram(g);if(!c.getProgramParameter(g,c.LINK_STATUS))throw Error("Could not compile WebGL program.\n\n"+
|
||||
c.getProgramInfoLog(g));d=a.m=g;c.useProgram(d);e=c.getUniformLocation(d,"sampler0");a.j={I:c.getAttribLocation(d,"aVertex"),H:c.getAttribLocation(d,"aTex"),da:e};a.s=c.createBuffer();c.bindBuffer(c.ARRAY_BUFFER,a.s);c.enableVertexAttribArray(a.j.I);c.vertexAttribPointer(a.j.I,2,c.FLOAT,!1,0,0);c.bufferData(c.ARRAY_BUFFER,new Float32Array([-1,-1,-1,1,1,1,1,-1]),c.STATIC_DRAW);c.bindBuffer(c.ARRAY_BUFFER,null);a.o=c.createBuffer();c.bindBuffer(c.ARRAY_BUFFER,a.o);c.enableVertexAttribArray(a.j.H);c.vertexAttribPointer(a.j.H,
|
||||
2,c.FLOAT,!1,0,0);c.bufferData(c.ARRAY_BUFFER,new Float32Array([0,1,0,0,1,0,1,1]),c.STATIC_DRAW);c.bindBuffer(c.ARRAY_BUFFER,null);c.uniform1i(e,0)}d=a.j;c.useProgram(a.m);c.canvas.width=b.width;c.canvas.height=b.height;c.viewport(0,0,b.width,b.height);c.activeTexture(c.TEXTURE0);a.h.bindTexture2d(b.glName);c.enableVertexAttribArray(d.I);c.bindBuffer(c.ARRAY_BUFFER,a.s);c.vertexAttribPointer(d.I,2,c.FLOAT,!1,0,0);c.enableVertexAttribArray(d.H);c.bindBuffer(c.ARRAY_BUFFER,a.o);c.vertexAttribPointer(d.H,
|
||||
2,c.FLOAT,!1,0,0);c.bindFramebuffer(c.DRAW_FRAMEBUFFER?c.DRAW_FRAMEBUFFER:c.FRAMEBUFFER,null);c.clearColor(0,0,0,0);c.clear(c.COLOR_BUFFER_BIT);c.colorMask(!0,!0,!0,!0);c.drawArrays(c.TRIANGLE_FAN,0,4);c.disableVertexAttribArray(d.I);c.disableVertexAttribArray(d.H);c.bindBuffer(c.ARRAY_BUFFER,null);a.h.bindTexture2d(0)}function Rb(a){this.g=a};var Sb=new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,9,1,7,0,65,0,253,15,26,11]);function Tb(a,b){return b+a}function Ub(a,b){window[a]=b}function Vb(a){var b=document.createElement("script");b.setAttribute("src",a);b.setAttribute("crossorigin","anonymous");return new Promise(function(c){b.addEventListener("load",function(){c()},!1);b.addEventListener("error",function(){c()},!1);document.body.appendChild(b)})}
|
||||
function Wb(){return Z(this,function b(){return O(b,function(c){switch(c.g){case 1:return c.m=2,N(c,WebAssembly.instantiate(Sb),4);case 4:c.g=3;c.m=0;break;case 2:return c.m=0,c.j=null,c.return(!1);case 3:return c.return(!0)}})})}
|
||||
function Xb(a){this.g=a;this.listeners={};this.j={};this.F={};this.m={};this.s={};this.G=this.o=this.R=!0;this.C=Promise.resolve();this.P="";this.B={};this.locateFile=a&&a.locateFile||Tb;if("object"===typeof window)var b=window.location.pathname.toString().substring(0,window.location.pathname.toString().lastIndexOf("/"))+"/";else if("undefined"!==typeof location)b=location.pathname.toString().substring(0,location.pathname.toString().lastIndexOf("/"))+"/";else throw Error("solutions can only be loaded on a web page or in a web worker");
|
||||
this.S=b;if(a.options){b=K(Object.keys(a.options));for(var c=b.next();!c.done;c=b.next()){c=c.value;var d=a.options[c].default;void 0!==d&&(this.j[c]="function"===typeof d?d():d)}}}v=Xb.prototype;v.close=function(){this.i&&this.i.delete();return Promise.resolve()};function Yb(a,b){return void 0===a.g.files?[]:"function"===typeof a.g.files?a.g.files(b):a.g.files}
|
||||
function Zb(a){return Z(a,function c(){var d=this,e,g,f,h,k,l,n,u,w,r,y;return O(c,function(m){switch(m.g){case 1:e=d;if(!d.R)return m.return();g=Yb(d,d.j);return N(m,Wb(),2);case 2:f=m.h;if("object"===typeof window)return Ub("createMediapipeSolutionsWasm",{locateFile:d.locateFile}),Ub("createMediapipeSolutionsPackedAssets",{locateFile:d.locateFile}),l=g.filter(function(t){return void 0!==t.data}),n=g.filter(function(t){return void 0===t.data}),u=Promise.all(l.map(function(t){var x=$b(e,t.url);if(void 0!==
|
||||
t.path){var z=t.path;x=x.then(function(E){e.overrideFile(z,E);return Promise.resolve(E)})}return x})),w=Promise.all(n.map(function(t){return void 0===t.simd||t.simd&&f||!t.simd&&!f?Vb(e.locateFile(t.url,e.S)):Promise.resolve()})).then(function(){return Z(e,function x(){var z,E,F=this;return O(x,function(I){if(1==I.g)return z=window.createMediapipeSolutionsWasm,E=window.createMediapipeSolutionsPackedAssets,N(I,z(E),2);F.h=I.h;I.g=0})})}),r=function(){return Z(e,function x(){var z=this;return O(x,function(E){z.g.graph&&
|
||||
z.g.graph.url?E=N(E,$b(z,z.g.graph.url),0):(E.g=0,E=void 0);return E})})}(),N(m,Promise.all([w,u,r]),7);if("function"!==typeof importScripts)throw Error("solutions can only be loaded on a web page or in a web worker");h=g.filter(function(t){return void 0===t.simd||t.simd&&f||!t.simd&&!f}).map(function(t){return e.locateFile(t.url,e.S)});importScripts.apply(null,L(h));return N(m,createMediapipeSolutionsWasm(Module),6);case 6:d.h=m.h;d.l=new OffscreenCanvas(1,1);d.h.canvas=d.l;k=d.h.GL.createContext(d.l,
|
||||
{antialias:!1,alpha:!1,ba:"undefined"!==typeof WebGL2RenderingContext?2:1});d.h.GL.makeContextCurrent(k);m.g=4;break;case 7:d.l=document.createElement("canvas");y=d.l.getContext("webgl2",{});if(!y&&(y=d.l.getContext("webgl",{}),!y))return alert("Failed to create WebGL canvas context when passing video frame."),m.return();d.D=y;d.h.canvas=d.l;d.h.createContext(d.l,!0,!0,{});case 4:d.i=new d.h.SolutionWasm,d.R=!1,m.g=0}})})}
|
||||
function ac(a){return Z(a,function c(){var d=this,e,g,f,h,k,l,n,u;return O(c,function(w){if(1==w.g){if(d.g.graph&&d.g.graph.url&&d.P===d.g.graph.url)return w.return();d.o=!0;if(!d.g.graph||!d.g.graph.url){w.g=2;return}d.P=d.g.graph.url;return N(w,$b(d,d.g.graph.url),3)}2!=w.g&&(e=w.h,d.i.loadGraph(e));g=K(Object.keys(d.B));for(f=g.next();!f.done;f=g.next())h=f.value,d.i.overrideFile(h,d.B[h]);d.B={};if(d.g.listeners)for(k=K(d.g.listeners),l=k.next();!l.done;l=k.next())n=l.value,bc(d,n);u=d.j;d.j=
|
||||
{};d.setOptions(u);w.g=0})})}v.reset=function(){return Z(this,function b(){var c=this;return O(b,function(d){c.i&&(c.i.reset(),c.m={},c.s={});d.g=0})})};
|
||||
v.setOptions=function(a,b){var c=this;if(b=b||this.g.options){for(var d=[],e=[],g={},f=K(Object.keys(a)),h=f.next();!h.done;g={K:g.K,L:g.L},h=f.next()){var k=h.value;k in this.j&&this.j[k]===a[k]||(this.j[k]=a[k],h=b[k],void 0!==h&&(h.onChange&&(g.K=h.onChange,g.L=a[k],d.push(function(l){return function(){return Z(c,function u(){var w,r=this;return O(u,function(y){if(1==y.g)return N(y,l.K(l.L),2);w=y.h;!0===w&&(r.o=!0);y.g=0})})}}(g))),h.graphOptionXref&&(k={valueNumber:1===h.type?a[k]:0,valueBoolean:0===
|
||||
h.type?a[k]:!1,valueString:2===h.type?a[k]:""},h=Object.assign(Object.assign(Object.assign({},{calculatorName:"",calculatorIndex:0}),h.graphOptionXref),k),e.push(h))))}if(0!==d.length||0!==e.length)this.o=!0,this.A=(void 0===this.A?[]:this.A).concat(e),this.u=(void 0===this.u?[]:this.u).concat(d)}};
|
||||
function cc(a){return Z(a,function c(){var d=this,e,g,f,h,k,l,n;return O(c,function(u){switch(u.g){case 1:if(!d.o)return u.return();if(!d.u){u.g=2;break}e=K(d.u);g=e.next();case 3:if(g.done){u.g=5;break}f=g.value;return N(u,f(),4);case 4:g=e.next();u.g=3;break;case 5:d.u=void 0;case 2:if(d.A){h=new d.h.GraphOptionChangeRequestList;k=K(d.A);for(l=k.next();!l.done;l=k.next())n=l.value,h.push_back(n);d.i.changeOptions(h);h.delete();d.A=void 0}d.o=!1;u.g=0}})})}
|
||||
v.initialize=function(){return Z(this,function b(){var c=this;return O(b,function(d){return 1==d.g?N(d,Zb(c),2):3!=d.g?N(d,ac(c),3):N(d,cc(c),0)})})};function $b(a,b){return Z(a,function d(){var e=this,g,f;return O(d,function(h){if(b in e.F)return h.return(e.F[b]);g=e.locateFile(b,"");f=fetch(g).then(function(k){return k.arrayBuffer()});e.F[b]=f;return h.return(f)})})}v.overrideFile=function(a,b){this.i?this.i.overrideFile(a,b):this.B[a]=b};v.clearOverriddenFiles=function(){this.B={};this.i&&this.i.clearOverriddenFiles()};
|
||||
v.send=function(a,b){return Z(this,function d(){var e=this,g,f,h,k,l,n,u,w,r;return O(d,function(y){switch(y.g){case 1:if(!e.g.inputs)return y.return();g=1E3*(void 0===b||null===b?performance.now():b);return N(y,e.C,2);case 2:return N(y,e.initialize(),3);case 3:f=new e.h.PacketDataList;h=K(Object.keys(a));for(k=h.next();!k.done;k=h.next())if(l=k.value,n=e.g.inputs[l]){a:{var m=e;var t=a[l];switch(n.type){case "video":var x=m.m[n.stream];x||(x=new Ob(m.h,m.D),m.m[n.stream]=x);m=x;0===m.l&&(m.l=m.h.createTexture());
|
||||
if("undefined"!==typeof HTMLVideoElement&&t instanceof HTMLVideoElement){var z=t.videoWidth;x=t.videoHeight}else"undefined"!==typeof HTMLImageElement&&t instanceof HTMLImageElement?(z=t.naturalWidth,x=t.naturalHeight):(z=t.width,x=t.height);x={glName:m.l,width:z,height:x};z=m.g;z.canvas.width=x.width;z.canvas.height=x.height;z.activeTexture(z.TEXTURE0);m.h.bindTexture2d(m.l);z.texImage2D(z.TEXTURE_2D,0,z.RGBA,z.RGBA,z.UNSIGNED_BYTE,t);m.h.bindTexture2d(0);m=x;break a;case "detections":x=m.m[n.stream];
|
||||
x||(x=new Rb(m.h),m.m[n.stream]=x);m=x;m.data||(m.data=new m.g.DetectionListData);m.data.reset(t.length);for(x=0;x<t.length;++x){z=t[x];var E=m.data,F=E.setBoundingBox,I=x;var H=z.T;var p=new Fb;X(p,1,H.Z);X(p,2,H.$);X(p,3,H.height);X(p,4,H.width);X(p,5,H.rotation);X(p,6,H.X);var A=H=new Ya;U(A,1,W(p,1));U(A,2,W(p,2));U(A,3,W(p,3));U(A,4,W(p,4));U(A,5,W(p,5));var C=W(p,6);if(null!=C&&null!=C){Ra(A.g,48);var q=A.g,B=C;C=0>B;B=Math.abs(B);var D=B>>>0;B=Math.floor((B-D)/4294967296);B>>>=0;C&&(B=~B>>>
|
||||
0,D=(~D>>>0)+1,4294967295<D&&(D=0,B++,4294967295<B&&(B=0)));Q=D;R=B;C=Q;for(D=R;0<D||127<C;)q.push(C&127|128),C=(C>>>7|D<<25)>>>0,D>>>=7;q.push(C)}rb(p,A);H=$a(H);F.call(E,I,H);if(z.O)for(E=0;E<z.O.length;++E)p=z.O[E],A=p.visibility?!0:!1,F=m.data,I=F.addNormalizedLandmark,H=x,p=Object.assign(Object.assign({},p),{visibility:A?p.visibility:0}),A=new Ab,X(A,1,p.x),X(A,2,p.y),X(A,3,p.z),p.visibility&&X(A,4,p.visibility),q=p=new Ya,U(q,1,W(A,1)),U(q,2,W(A,2)),U(q,3,W(A,3)),U(q,4,W(A,4)),U(q,5,W(A,5)),
|
||||
rb(A,q),p=$a(p),I.call(F,H,p);if(z.M)for(E=0;E<z.M.length;++E){F=m.data;I=F.addClassification;H=x;p=z.M[E];A=new wb;X(A,2,p.Y);p.index&&X(A,1,p.index);p.label&&X(A,3,p.label);p.displayName&&X(A,4,p.displayName);q=p=new Ya;D=W(A,1);if(null!=D&&null!=D)if(Ra(q.g,8),C=q.g,0<=D)Ra(C,D);else{for(B=0;9>B;B++)C.push(D&127|128),D>>=7;C.push(1)}U(q,2,W(A,2));C=W(A,3);null!=C&&(C=Ca(C),Ra(q.g,26),Ra(q.g,C.length),Za(q,q.g.end()),Za(q,C));C=W(A,4);null!=C&&(C=Ca(C),Ra(q.g,34),Ra(q.g,C.length),Za(q,q.g.end()),
|
||||
Za(q,C));rb(A,q);p=$a(p);I.call(F,H,p)}}m=m.data;break a;default:m={}}}u=m;w=n.stream;switch(n.type){case "video":f.pushTexture2d(Object.assign(Object.assign({},u),{stream:w,timestamp:g}));break;case "detections":r=u;r.stream=w;r.timestamp=g;f.pushDetectionList(r);break;default:throw Error("Unknown input config type: '"+n.type+"'");}}e.i.send(f);return N(y,e.C,4);case 4:f.delete(),y.g=0}})})};
|
||||
function dc(a,b,c){return Z(a,function e(){var g,f,h,k,l,n,u=this,w,r,y,m,t,x,z,E;return O(e,function(F){switch(F.g){case 1:if(!c)return F.return(b);g={};f=0;h=K(Object.keys(c));for(k=h.next();!k.done;k=h.next())l=k.value,n=c[l],"string"!==typeof n&&"texture"===n.type&&void 0!==b[n.stream]&&++f;1<f&&(u.G=!1);w=K(Object.keys(c));k=w.next();case 2:if(k.done){F.g=4;break}r=k.value;y=c[r];if("string"===typeof y)return z=g,E=r,N(F,ec(u,r,b[y]),14);m=b[y.stream];if("detection_list"===y.type){if(m){var I=
|
||||
m.getRectList();for(var H=m.getLandmarksList(),p=m.getClassificationsList(),A=[],C=0;C<I.size();++C){var q=I.get(C);a:{var B=new Fb;for(q=new Sa(q);S(q);)switch(q.i){case 13:var D=T(q);X(B,1,D);break;case 21:D=T(q);X(B,2,D);break;case 29:D=T(q);X(B,3,D);break;case 37:D=T(q);X(B,4,D);break;case 45:D=T(q);X(B,5,D);break;case 48:D=Oa(q.g);X(B,6,D);break;default:if(!sb(B,q))break a}}B={Z:Y(B,1),$:Y(B,2),height:Y(B,3),width:Y(B,4),rotation:Y(B,5,0),X:lb(B,6,0)};q=nb(Eb(H.get(C)),Ab).map(Nb);var la=p.get(C);
|
||||
a:for(D=new yb,la=new Sa(la);S(la);)switch(la.i){case 10:D.addClassification(Ua(la,new wb,xb));break;default:if(!sb(D,la))break a}B={T:B,O:q,M:Mb(D)};A.push(B)}I=A}else I=[];g[r]=I;F.g=7;break}if("proto_list"===y.type){if(m){I=Array(m.size());for(H=0;H<m.size();H++)I[H]=m.get(H);m.delete()}else I=[];g[r]=I;F.g=7;break}if(void 0===m){F.g=3;break}if("float_list"===y.type){g[r]=m;F.g=7;break}if("proto"===y.type){g[r]=m;F.g=7;break}if("texture"!==y.type)throw Error("Unknown output config type: '"+y.type+
|
||||
"'");t=u.s[r];t||(t=new Ob(u.h,u.D),u.s[r]=t);return N(F,Pb(t,m,u.G),13);case 13:x=F.h,g[r]=x;case 7:y.transform&&g[r]&&(g[r]=y.transform(g[r]));F.g=3;break;case 14:z[E]=F.h;case 3:k=w.next();F.g=2;break;case 4:return F.return(g)}})})}
|
||||
function ec(a,b,c){return Z(a,function e(){var g=this,f;return O(e,function(h){return"number"===typeof c||c instanceof Uint8Array||c instanceof g.h.Uint8BlobList?h.return(c):c instanceof g.h.Texture2dDataOut?(f=g.s[b],f||(f=new Ob(g.h,g.D),g.s[b]=f),h.return(Pb(f,c,g.G))):h.return(void 0)})})}
|
||||
function bc(a,b){for(var c=b.name||"$",d=[].concat(L(b.wants)),e=new a.h.StringList,g=K(b.wants),f=g.next();!f.done;f=g.next())e.push_back(f.value);g=a.h.PacketListener.implement({onResults:function(h){for(var k={},l=0;l<b.wants.length;++l)k[d[l]]=h.get(l);var n=a.listeners[c];n&&(a.C=dc(a,k,b.outs).then(function(u){u=n(u);for(var w=0;w<b.wants.length;++w){var r=k[d[w]];"object"===typeof r&&r.hasOwnProperty&&r.hasOwnProperty("delete")&&r.delete()}u&&(a.C=u)}))}});a.i.attachMultiListener(e,g);e.delete()}
|
||||
v.onResults=function(a,b){this.listeners[b||"$"]=a};P("Solution",Xb);P("OptionType",{BOOL:0,NUMBER:1,aa:2,0:"BOOL",1:"NUMBER",2:"STRING"});function fc(a){a=Kb(a);var b=a.getMesh();if(!b)return a;var c=new Float32Array(b.getVertexBufferList());b.getVertexBufferList=function(){return c};var d=new Uint32Array(b.getIndexBufferList());b.getIndexBufferList=function(){return d};return a};var gc={files:[{url:"face_mesh_solution_packed_assets_loader.js"},{simd:!0,url:"face_mesh_solution_simd_wasm_bin.js"},{simd:!1,url:"face_mesh_solution_wasm_bin.js"}],graph:{url:"face_mesh.binarypb"},listeners:[{wants:["multi_face_geometry","image_transformed","multi_face_landmarks"],outs:{image:"image_transformed",multiFaceGeometry:{type:"proto_list",stream:"multi_face_geometry",transform:function(a){return a.map(fc)}},multiFaceLandmarks:{type:"proto_list",stream:"multi_face_landmarks",transform:function(a){return a.map(function(b){return nb(Eb(b),
|
||||
Ab).map(Nb)})}}}}],inputs:{image:{type:"video",stream:"input_frames_gpu"}},options:{useCpuInference:{type:0,graphOptionXref:{calculatorType:"InferenceCalculator",fieldName:"use_cpu_inference"},default:"iPad Simulator;iPhone Simulator;iPod Simulator;iPad;iPhone;iPod".split(";").includes(navigator.platform)||navigator.userAgent.includes("Mac")&&"ontouchend"in document},enableFaceGeometry:{type:0,graphOptionXref:{calculatorName:"EnableFaceGeometryConstant",calculatorType:"ConstantSidePacketCalculator",
|
||||
fieldName:"bool_value"}},selfieMode:{type:0,graphOptionXref:{calculatorType:"GlScalerCalculator",calculatorIndex:1,fieldName:"flip_horizontal"}},maxNumFaces:{type:1,graphOptionXref:{calculatorType:"ConstantSidePacketCalculator",calculatorName:"ConstantSidePacketCalculatorNumFaces",fieldName:"int_value"}},refineLandmarks:{type:0,graphOptionXref:{calculatorType:"ConstantSidePacketCalculator",calculatorName:"ConstantSidePacketCalculatorRefineLandmarks",fieldName:"bool_value"}},minDetectionConfidence:{type:1,
|
||||
graphOptionXref:{calculatorType:"TensorsToDetectionsCalculator",calculatorName:"facelandmarkfrontgpu__facedetectionshortrangegpu__facedetectionshortrangecommon__TensorsToDetectionsCalculator",fieldName:"min_score_thresh"}},minTrackingConfidence:{type:1,graphOptionXref:{calculatorType:"ThresholdingCalculator",calculatorName:"facelandmarkfrontgpu__facelandmarkgpu__ThresholdingCalculator",fieldName:"threshold"}},cameraNear:{type:1,graphOptionXref:{calculatorType:"FaceGeometryEnvGeneratorCalculator",
|
||||
fieldName:"near"}},cameraFar:{type:1,graphOptionXref:{calculatorType:"FaceGeometryEnvGeneratorCalculator",fieldName:"far"}},cameraVerticalFovDegrees:{type:1,graphOptionXref:{calculatorType:"FaceGeometryEnvGeneratorCalculator",fieldName:"vertical_fov_degrees"}}}};var hc=[[61,146],[146,91],[91,181],[181,84],[84,17],[17,314],[314,405],[405,321],[321,375],[375,291],[61,185],[185,40],[40,39],[39,37],[37,0],[0,267],[267,269],[269,270],[270,409],[409,291],[78,95],[95,88],[88,178],[178,87],[87,14],[14,317],[317,402],[402,318],[318,324],[324,308],[78,191],[191,80],[80,81],[81,82],[82,13],[13,312],[312,311],[311,310],[310,415],[415,308]],ic=[[263,249],[249,390],[390,373],[373,374],[374,380],[380,381],[381,382],[382,362],[263,466],[466,388],[388,387],[387,386],[386,
|
||||
385],[385,384],[384,398],[398,362]],jc=[[276,283],[283,282],[282,295],[295,285],[300,293],[293,334],[334,296],[296,336]],kc=[[33,7],[7,163],[163,144],[144,145],[145,153],[153,154],[154,155],[155,133],[33,246],[246,161],[161,160],[160,159],[159,158],[158,157],[157,173],[173,133]],lc=[[46,53],[53,52],[52,65],[65,55],[70,63],[63,105],[105,66],[66,107]],mc=[[10,338],[338,297],[297,332],[332,284],[284,251],[251,389],[389,356],[356,454],[454,323],[323,361],[361,288],[288,397],[397,365],[365,379],[379,378],
|
||||
[378,400],[400,377],[377,152],[152,148],[148,176],[176,149],[149,150],[150,136],[136,172],[172,58],[58,132],[132,93],[93,234],[234,127],[127,162],[162,21],[21,54],[54,103],[103,67],[67,109],[109,10]],nc=[].concat(L(hc),L(ic),L(jc),L(kc),L(lc),L(mc));function oc(a){a=a||{};a=Object.assign(Object.assign({},gc),a);this.g=new Xb(a)}v=oc.prototype;v.close=function(){this.g.close();return Promise.resolve()};v.onResults=function(a){this.g.onResults(a)};v.initialize=function(){return Z(this,function b(){var c=this;return O(b,function(d){return N(d,c.g.initialize(),0)})})};v.reset=function(){this.g.reset()};v.send=function(a){return Z(this,function c(){var d=this;return O(c,function(e){return N(e,d.g.send(a),0)})})};v.setOptions=function(a){this.g.setOptions(a)};
|
||||
P("FACE_GEOMETRY",{Layout:{COLUMN_MAJOR:0,ROW_MAJOR:1,0:"COLUMN_MAJOR",1:"ROW_MAJOR"},PrimitiveType:{TRIANGLE:0,0:"TRIANGLE"},VertexType:{VERTEX_PT:0,0:"VERTEX_PT"},DEFAULT_CAMERA_PARAMS:{verticalFovDegrees:63,near:1,far:1E4}});P("FaceMesh",oc);P("FACEMESH_LIPS",hc);P("FACEMESH_LEFT_EYE",ic);P("FACEMESH_LEFT_EYEBROW",jc);P("FACEMESH_LEFT_IRIS",[[474,475],[475,476],[476,477],[477,474]]);P("FACEMESH_RIGHT_EYE",kc);P("FACEMESH_RIGHT_EYEBROW",lc);
|
||||
P("FACEMESH_RIGHT_IRIS",[[469,470],[470,471],[471,472],[472,469]]);P("FACEMESH_FACE_OVAL",mc);P("FACEMESH_CONTOURS",nc);
|
||||
P("FACEMESH_TESSELATION",[[127,34],[34,139],[139,127],[11,0],[0,37],[37,11],[232,231],[231,120],[120,232],[72,37],[37,39],[39,72],[128,121],[121,47],[47,128],[232,121],[121,128],[128,232],[104,69],[69,67],[67,104],[175,171],[171,148],[148,175],[118,50],[50,101],[101,118],[73,39],[39,40],[40,73],[9,151],[151,108],[108,9],[48,115],[115,131],[131,48],[194,204],[204,211],[211,194],[74,40],[40,185],[185,74],[80,42],[42,183],[183,80],[40,92],[92,186],[186,40],[230,229],[229,118],[118,230],[202,212],[212,
|
||||
214],[214,202],[83,18],[18,17],[17,83],[76,61],[61,146],[146,76],[160,29],[29,30],[30,160],[56,157],[157,173],[173,56],[106,204],[204,194],[194,106],[135,214],[214,192],[192,135],[203,165],[165,98],[98,203],[21,71],[71,68],[68,21],[51,45],[45,4],[4,51],[144,24],[24,23],[23,144],[77,146],[146,91],[91,77],[205,50],[50,187],[187,205],[201,200],[200,18],[18,201],[91,106],[106,182],[182,91],[90,91],[91,181],[181,90],[85,84],[84,17],[17,85],[206,203],[203,36],[36,206],[148,171],[171,140],[140,148],[92,
|
||||
40],[40,39],[39,92],[193,189],[189,244],[244,193],[159,158],[158,28],[28,159],[247,246],[246,161],[161,247],[236,3],[3,196],[196,236],[54,68],[68,104],[104,54],[193,168],[168,8],[8,193],[117,228],[228,31],[31,117],[189,193],[193,55],[55,189],[98,97],[97,99],[99,98],[126,47],[47,100],[100,126],[166,79],[79,218],[218,166],[155,154],[154,26],[26,155],[209,49],[49,131],[131,209],[135,136],[136,150],[150,135],[47,126],[126,217],[217,47],[223,52],[52,53],[53,223],[45,51],[51,134],[134,45],[211,170],[170,
|
||||
140],[140,211],[67,69],[69,108],[108,67],[43,106],[106,91],[91,43],[230,119],[119,120],[120,230],[226,130],[130,247],[247,226],[63,53],[53,52],[52,63],[238,20],[20,242],[242,238],[46,70],[70,156],[156,46],[78,62],[62,96],[96,78],[46,53],[53,63],[63,46],[143,34],[34,227],[227,143],[123,117],[117,111],[111,123],[44,125],[125,19],[19,44],[236,134],[134,51],[51,236],[216,206],[206,205],[205,216],[154,153],[153,22],[22,154],[39,37],[37,167],[167,39],[200,201],[201,208],[208,200],[36,142],[142,100],[100,
|
||||
36],[57,212],[212,202],[202,57],[20,60],[60,99],[99,20],[28,158],[158,157],[157,28],[35,226],[226,113],[113,35],[160,159],[159,27],[27,160],[204,202],[202,210],[210,204],[113,225],[225,46],[46,113],[43,202],[202,204],[204,43],[62,76],[76,77],[77,62],[137,123],[123,116],[116,137],[41,38],[38,72],[72,41],[203,129],[129,142],[142,203],[64,98],[98,240],[240,64],[49,102],[102,64],[64,49],[41,73],[73,74],[74,41],[212,216],[216,207],[207,212],[42,74],[74,184],[184,42],[169,170],[170,211],[211,169],[170,
|
||||
149],[149,176],[176,170],[105,66],[66,69],[69,105],[122,6],[6,168],[168,122],[123,147],[147,187],[187,123],[96,77],[77,90],[90,96],[65,55],[55,107],[107,65],[89,90],[90,180],[180,89],[101,100],[100,120],[120,101],[63,105],[105,104],[104,63],[93,137],[137,227],[227,93],[15,86],[86,85],[85,15],[129,102],[102,49],[49,129],[14,87],[87,86],[86,14],[55,8],[8,9],[9,55],[100,47],[47,121],[121,100],[145,23],[23,22],[22,145],[88,89],[89,179],[179,88],[6,122],[122,196],[196,6],[88,95],[95,96],[96,88],[138,172],
|
||||
[172,136],[136,138],[215,58],[58,172],[172,215],[115,48],[48,219],[219,115],[42,80],[80,81],[81,42],[195,3],[3,51],[51,195],[43,146],[146,61],[61,43],[171,175],[175,199],[199,171],[81,82],[82,38],[38,81],[53,46],[46,225],[225,53],[144,163],[163,110],[110,144],[52,65],[65,66],[66,52],[229,228],[228,117],[117,229],[34,127],[127,234],[234,34],[107,108],[108,69],[69,107],[109,108],[108,151],[151,109],[48,64],[64,235],[235,48],[62,78],[78,191],[191,62],[129,209],[209,126],[126,129],[111,35],[35,143],[143,
|
||||
111],[117,123],[123,50],[50,117],[222,65],[65,52],[52,222],[19,125],[125,141],[141,19],[221,55],[55,65],[65,221],[3,195],[195,197],[197,3],[25,7],[7,33],[33,25],[220,237],[237,44],[44,220],[70,71],[71,139],[139,70],[122,193],[193,245],[245,122],[247,130],[130,33],[33,247],[71,21],[21,162],[162,71],[170,169],[169,150],[150,170],[188,174],[174,196],[196,188],[216,186],[186,92],[92,216],[2,97],[97,167],[167,2],[141,125],[125,241],[241,141],[164,167],[167,37],[37,164],[72,38],[38,12],[12,72],[38,82],
|
||||
[82,13],[13,38],[63,68],[68,71],[71,63],[226,35],[35,111],[111,226],[101,50],[50,205],[205,101],[206,92],[92,165],[165,206],[209,198],[198,217],[217,209],[165,167],[167,97],[97,165],[220,115],[115,218],[218,220],[133,112],[112,243],[243,133],[239,238],[238,241],[241,239],[214,135],[135,169],[169,214],[190,173],[173,133],[133,190],[171,208],[208,32],[32,171],[125,44],[44,237],[237,125],[86,87],[87,178],[178,86],[85,86],[86,179],[179,85],[84,85],[85,180],[180,84],[83,84],[84,181],[181,83],[201,83],
|
||||
[83,182],[182,201],[137,93],[93,132],[132,137],[76,62],[62,183],[183,76],[61,76],[76,184],[184,61],[57,61],[61,185],[185,57],[212,57],[57,186],[186,212],[214,207],[207,187],[187,214],[34,143],[143,156],[156,34],[79,239],[239,237],[237,79],[123,137],[137,177],[177,123],[44,1],[1,4],[4,44],[201,194],[194,32],[32,201],[64,102],[102,129],[129,64],[213,215],[215,138],[138,213],[59,166],[166,219],[219,59],[242,99],[99,97],[97,242],[2,94],[94,141],[141,2],[75,59],[59,235],[235,75],[24,110],[110,228],[228,
|
||||
24],[25,130],[130,226],[226,25],[23,24],[24,229],[229,23],[22,23],[23,230],[230,22],[26,22],[22,231],[231,26],[112,26],[26,232],[232,112],[189,190],[190,243],[243,189],[221,56],[56,190],[190,221],[28,56],[56,221],[221,28],[27,28],[28,222],[222,27],[29,27],[27,223],[223,29],[30,29],[29,224],[224,30],[247,30],[30,225],[225,247],[238,79],[79,20],[20,238],[166,59],[59,75],[75,166],[60,75],[75,240],[240,60],[147,177],[177,215],[215,147],[20,79],[79,166],[166,20],[187,147],[147,213],[213,187],[112,233],
|
||||
[233,244],[244,112],[233,128],[128,245],[245,233],[128,114],[114,188],[188,128],[114,217],[217,174],[174,114],[131,115],[115,220],[220,131],[217,198],[198,236],[236,217],[198,131],[131,134],[134,198],[177,132],[132,58],[58,177],[143,35],[35,124],[124,143],[110,163],[163,7],[7,110],[228,110],[110,25],[25,228],[356,389],[389,368],[368,356],[11,302],[302,267],[267,11],[452,350],[350,349],[349,452],[302,303],[303,269],[269,302],[357,343],[343,277],[277,357],[452,453],[453,357],[357,452],[333,332],[332,
|
||||
297],[297,333],[175,152],[152,377],[377,175],[347,348],[348,330],[330,347],[303,304],[304,270],[270,303],[9,336],[336,337],[337,9],[278,279],[279,360],[360,278],[418,262],[262,431],[431,418],[304,408],[408,409],[409,304],[310,415],[415,407],[407,310],[270,409],[409,410],[410,270],[450,348],[348,347],[347,450],[422,430],[430,434],[434,422],[313,314],[314,17],[17,313],[306,307],[307,375],[375,306],[387,388],[388,260],[260,387],[286,414],[414,398],[398,286],[335,406],[406,418],[418,335],[364,367],[367,
|
||||
416],[416,364],[423,358],[358,327],[327,423],[251,284],[284,298],[298,251],[281,5],[5,4],[4,281],[373,374],[374,253],[253,373],[307,320],[320,321],[321,307],[425,427],[427,411],[411,425],[421,313],[313,18],[18,421],[321,405],[405,406],[406,321],[320,404],[404,405],[405,320],[315,16],[16,17],[17,315],[426,425],[425,266],[266,426],[377,400],[400,369],[369,377],[322,391],[391,269],[269,322],[417,465],[465,464],[464,417],[386,257],[257,258],[258,386],[466,260],[260,388],[388,466],[456,399],[399,419],
|
||||
[419,456],[284,332],[332,333],[333,284],[417,285],[285,8],[8,417],[346,340],[340,261],[261,346],[413,441],[441,285],[285,413],[327,460],[460,328],[328,327],[355,371],[371,329],[329,355],[392,439],[439,438],[438,392],[382,341],[341,256],[256,382],[429,420],[420,360],[360,429],[364,394],[394,379],[379,364],[277,343],[343,437],[437,277],[443,444],[444,283],[283,443],[275,440],[440,363],[363,275],[431,262],[262,369],[369,431],[297,338],[338,337],[337,297],[273,375],[375,321],[321,273],[450,451],[451,
|
||||
349],[349,450],[446,342],[342,467],[467,446],[293,334],[334,282],[282,293],[458,461],[461,462],[462,458],[276,353],[353,383],[383,276],[308,324],[324,325],[325,308],[276,300],[300,293],[293,276],[372,345],[345,447],[447,372],[352,345],[345,340],[340,352],[274,1],[1,19],[19,274],[456,248],[248,281],[281,456],[436,427],[427,425],[425,436],[381,256],[256,252],[252,381],[269,391],[391,393],[393,269],[200,199],[199,428],[428,200],[266,330],[330,329],[329,266],[287,273],[273,422],[422,287],[250,462],[462,
|
||||
328],[328,250],[258,286],[286,384],[384,258],[265,353],[353,342],[342,265],[387,259],[259,257],[257,387],[424,431],[431,430],[430,424],[342,353],[353,276],[276,342],[273,335],[335,424],[424,273],[292,325],[325,307],[307,292],[366,447],[447,345],[345,366],[271,303],[303,302],[302,271],[423,266],[266,371],[371,423],[294,455],[455,460],[460,294],[279,278],[278,294],[294,279],[271,272],[272,304],[304,271],[432,434],[434,427],[427,432],[272,407],[407,408],[408,272],[394,430],[430,431],[431,394],[395,369],
|
||||
[369,400],[400,395],[334,333],[333,299],[299,334],[351,417],[417,168],[168,351],[352,280],[280,411],[411,352],[325,319],[319,320],[320,325],[295,296],[296,336],[336,295],[319,403],[403,404],[404,319],[330,348],[348,349],[349,330],[293,298],[298,333],[333,293],[323,454],[454,447],[447,323],[15,16],[16,315],[315,15],[358,429],[429,279],[279,358],[14,15],[15,316],[316,14],[285,336],[336,9],[9,285],[329,349],[349,350],[350,329],[374,380],[380,252],[252,374],[318,402],[402,403],[403,318],[6,197],[197,
|
||||
419],[419,6],[318,319],[319,325],[325,318],[367,364],[364,365],[365,367],[435,367],[367,397],[397,435],[344,438],[438,439],[439,344],[272,271],[271,311],[311,272],[195,5],[5,281],[281,195],[273,287],[287,291],[291,273],[396,428],[428,199],[199,396],[311,271],[271,268],[268,311],[283,444],[444,445],[445,283],[373,254],[254,339],[339,373],[282,334],[334,296],[296,282],[449,347],[347,346],[346,449],[264,447],[447,454],[454,264],[336,296],[296,299],[299,336],[338,10],[10,151],[151,338],[278,439],[439,
|
||||
455],[455,278],[292,407],[407,415],[415,292],[358,371],[371,355],[355,358],[340,345],[345,372],[372,340],[346,347],[347,280],[280,346],[442,443],[443,282],[282,442],[19,94],[94,370],[370,19],[441,442],[442,295],[295,441],[248,419],[419,197],[197,248],[263,255],[255,359],[359,263],[440,275],[275,274],[274,440],[300,383],[383,368],[368,300],[351,412],[412,465],[465,351],[263,467],[467,466],[466,263],[301,368],[368,389],[389,301],[395,378],[378,379],[379,395],[412,351],[351,419],[419,412],[436,426],
|
||||
[426,322],[322,436],[2,164],[164,393],[393,2],[370,462],[462,461],[461,370],[164,0],[0,267],[267,164],[302,11],[11,12],[12,302],[268,12],[12,13],[13,268],[293,300],[300,301],[301,293],[446,261],[261,340],[340,446],[330,266],[266,425],[425,330],[426,423],[423,391],[391,426],[429,355],[355,437],[437,429],[391,327],[327,326],[326,391],[440,457],[457,438],[438,440],[341,382],[382,362],[362,341],[459,457],[457,461],[461,459],[434,430],[430,394],[394,434],[414,463],[463,362],[362,414],[396,369],[369,262],
|
||||
[262,396],[354,461],[461,457],[457,354],[316,403],[403,402],[402,316],[315,404],[404,403],[403,315],[314,405],[405,404],[404,314],[313,406],[406,405],[405,313],[421,418],[418,406],[406,421],[366,401],[401,361],[361,366],[306,408],[408,407],[407,306],[291,409],[409,408],[408,291],[287,410],[410,409],[409,287],[432,436],[436,410],[410,432],[434,416],[416,411],[411,434],[264,368],[368,383],[383,264],[309,438],[438,457],[457,309],[352,376],[376,401],[401,352],[274,275],[275,4],[4,274],[421,428],[428,
|
||||
262],[262,421],[294,327],[327,358],[358,294],[433,416],[416,367],[367,433],[289,455],[455,439],[439,289],[462,370],[370,326],[326,462],[2,326],[326,370],[370,2],[305,460],[460,455],[455,305],[254,449],[449,448],[448,254],[255,261],[261,446],[446,255],[253,450],[450,449],[449,253],[252,451],[451,450],[450,252],[256,452],[452,451],[451,256],[341,453],[453,452],[452,341],[413,464],[464,463],[463,413],[441,413],[413,414],[414,441],[258,442],[442,441],[441,258],[257,443],[443,442],[442,257],[259,444],
|
||||
[444,443],[443,259],[260,445],[445,444],[444,260],[467,342],[342,445],[445,467],[459,458],[458,250],[250,459],[289,392],[392,290],[290,289],[290,328],[328,460],[460,290],[376,433],[433,435],[435,376],[250,290],[290,392],[392,250],[411,416],[416,433],[433,411],[341,463],[463,464],[464,341],[453,464],[464,465],[465,453],[357,465],[465,412],[412,357],[343,412],[412,399],[399,343],[360,363],[363,440],[440,360],[437,399],[399,456],[456,437],[420,456],[456,363],[363,420],[401,435],[435,288],[288,401],[372,
|
||||
383],[383,353],[353,372],[339,255],[255,249],[249,339],[448,261],[261,255],[255,448],[133,243],[243,190],[190,133],[133,155],[155,112],[112,133],[33,246],[246,247],[247,33],[33,130],[130,25],[25,33],[398,384],[384,286],[286,398],[362,398],[398,414],[414,362],[362,463],[463,341],[341,362],[263,359],[359,467],[467,263],[263,249],[249,255],[255,263],[466,467],[467,260],[260,466],[75,60],[60,166],[166,75],[238,239],[239,79],[79,238],[162,127],[127,139],[139,162],[72,11],[11,37],[37,72],[121,232],[232,
|
||||
120],[120,121],[73,72],[72,39],[39,73],[114,128],[128,47],[47,114],[233,232],[232,128],[128,233],[103,104],[104,67],[67,103],[152,175],[175,148],[148,152],[119,118],[118,101],[101,119],[74,73],[73,40],[40,74],[107,9],[9,108],[108,107],[49,48],[48,131],[131,49],[32,194],[194,211],[211,32],[184,74],[74,185],[185,184],[191,80],[80,183],[183,191],[185,40],[40,186],[186,185],[119,230],[230,118],[118,119],[210,202],[202,214],[214,210],[84,83],[83,17],[17,84],[77,76],[76,146],[146,77],[161,160],[160,30],
|
||||
[30,161],[190,56],[56,173],[173,190],[182,106],[106,194],[194,182],[138,135],[135,192],[192,138],[129,203],[203,98],[98,129],[54,21],[21,68],[68,54],[5,51],[51,4],[4,5],[145,144],[144,23],[23,145],[90,77],[77,91],[91,90],[207,205],[205,187],[187,207],[83,201],[201,18],[18,83],[181,91],[91,182],[182,181],[180,90],[90,181],[181,180],[16,85],[85,17],[17,16],[205,206],[206,36],[36,205],[176,148],[148,140],[140,176],[165,92],[92,39],[39,165],[245,193],[193,244],[244,245],[27,159],[159,28],[28,27],[30,
|
||||
247],[247,161],[161,30],[174,236],[236,196],[196,174],[103,54],[54,104],[104,103],[55,193],[193,8],[8,55],[111,117],[117,31],[31,111],[221,189],[189,55],[55,221],[240,98],[98,99],[99,240],[142,126],[126,100],[100,142],[219,166],[166,218],[218,219],[112,155],[155,26],[26,112],[198,209],[209,131],[131,198],[169,135],[135,150],[150,169],[114,47],[47,217],[217,114],[224,223],[223,53],[53,224],[220,45],[45,134],[134,220],[32,211],[211,140],[140,32],[109,67],[67,108],[108,109],[146,43],[43,91],[91,146],
|
||||
[231,230],[230,120],[120,231],[113,226],[226,247],[247,113],[105,63],[63,52],[52,105],[241,238],[238,242],[242,241],[124,46],[46,156],[156,124],[95,78],[78,96],[96,95],[70,46],[46,63],[63,70],[116,143],[143,227],[227,116],[116,123],[123,111],[111,116],[1,44],[44,19],[19,1],[3,236],[236,51],[51,3],[207,216],[216,205],[205,207],[26,154],[154,22],[22,26],[165,39],[39,167],[167,165],[199,200],[200,208],[208,199],[101,36],[36,100],[100,101],[43,57],[57,202],[202,43],[242,20],[20,99],[99,242],[56,28],[28,
|
||||
157],[157,56],[124,35],[35,113],[113,124],[29,160],[160,27],[27,29],[211,204],[204,210],[210,211],[124,113],[113,46],[46,124],[106,43],[43,204],[204,106],[96,62],[62,77],[77,96],[227,137],[137,116],[116,227],[73,41],[41,72],[72,73],[36,203],[203,142],[142,36],[235,64],[64,240],[240,235],[48,49],[49,64],[64,48],[42,41],[41,74],[74,42],[214,212],[212,207],[207,214],[183,42],[42,184],[184,183],[210,169],[169,211],[211,210],[140,170],[170,176],[176,140],[104,105],[105,69],[69,104],[193,122],[122,168],
|
||||
[168,193],[50,123],[123,187],[187,50],[89,96],[96,90],[90,89],[66,65],[65,107],[107,66],[179,89],[89,180],[180,179],[119,101],[101,120],[120,119],[68,63],[63,104],[104,68],[234,93],[93,227],[227,234],[16,15],[15,85],[85,16],[209,129],[129,49],[49,209],[15,14],[14,86],[86,15],[107,55],[55,9],[9,107],[120,100],[100,121],[121,120],[153,145],[145,22],[22,153],[178,88],[88,179],[179,178],[197,6],[6,196],[196,197],[89,88],[88,96],[96,89],[135,138],[138,136],[136,135],[138,215],[215,172],[172,138],[218,
|
||||
115],[115,219],[219,218],[41,42],[42,81],[81,41],[5,195],[195,51],[51,5],[57,43],[43,61],[61,57],[208,171],[171,199],[199,208],[41,81],[81,38],[38,41],[224,53],[53,225],[225,224],[24,144],[144,110],[110,24],[105,52],[52,66],[66,105],[118,229],[229,117],[117,118],[227,34],[34,234],[234,227],[66,107],[107,69],[69,66],[10,109],[109,151],[151,10],[219,48],[48,235],[235,219],[183,62],[62,191],[191,183],[142,129],[129,126],[126,142],[116,111],[111,143],[143,116],[118,117],[117,50],[50,118],[223,222],[222,
|
||||
52],[52,223],[94,19],[19,141],[141,94],[222,221],[221,65],[65,222],[196,3],[3,197],[197,196],[45,220],[220,44],[44,45],[156,70],[70,139],[139,156],[188,122],[122,245],[245,188],[139,71],[71,162],[162,139],[149,170],[170,150],[150,149],[122,188],[188,196],[196,122],[206,216],[216,92],[92,206],[164,2],[2,167],[167,164],[242,141],[141,241],[241,242],[0,164],[164,37],[37,0],[11,72],[72,12],[12,11],[12,38],[38,13],[13,12],[70,63],[63,71],[71,70],[31,226],[226,111],[111,31],[36,101],[101,205],[205,36],
|
||||
[203,206],[206,165],[165,203],[126,209],[209,217],[217,126],[98,165],[165,97],[97,98],[237,220],[220,218],[218,237],[237,239],[239,241],[241,237],[210,214],[214,169],[169,210],[140,171],[171,32],[32,140],[241,125],[125,237],[237,241],[179,86],[86,178],[178,179],[180,85],[85,179],[179,180],[181,84],[84,180],[180,181],[182,83],[83,181],[181,182],[194,201],[201,182],[182,194],[177,137],[137,132],[132,177],[184,76],[76,183],[183,184],[185,61],[61,184],[184,185],[186,57],[57,185],[185,186],[216,212],[212,
|
||||
186],[186,216],[192,214],[214,187],[187,192],[139,34],[34,156],[156,139],[218,79],[79,237],[237,218],[147,123],[123,177],[177,147],[45,44],[44,4],[4,45],[208,201],[201,32],[32,208],[98,64],[64,129],[129,98],[192,213],[213,138],[138,192],[235,59],[59,219],[219,235],[141,242],[242,97],[97,141],[97,2],[2,141],[141,97],[240,75],[75,235],[235,240],[229,24],[24,228],[228,229],[31,25],[25,226],[226,31],[230,23],[23,229],[229,230],[231,22],[22,230],[230,231],[232,26],[26,231],[231,232],[233,112],[112,232],
|
||||
[232,233],[244,189],[189,243],[243,244],[189,221],[221,190],[190,189],[222,28],[28,221],[221,222],[223,27],[27,222],[222,223],[224,29],[29,223],[223,224],[225,30],[30,224],[224,225],[113,247],[247,225],[225,113],[99,60],[60,240],[240,99],[213,147],[147,215],[215,213],[60,20],[20,166],[166,60],[192,187],[187,213],[213,192],[243,112],[112,244],[244,243],[244,233],[233,245],[245,244],[245,128],[128,188],[188,245],[188,114],[114,174],[174,188],[134,131],[131,220],[220,134],[174,217],[217,236],[236,174],
|
||||
[236,198],[198,134],[134,236],[215,177],[177,58],[58,215],[156,143],[143,124],[124,156],[25,110],[110,7],[7,25],[31,228],[228,25],[25,31],[264,356],[356,368],[368,264],[0,11],[11,267],[267,0],[451,452],[452,349],[349,451],[267,302],[302,269],[269,267],[350,357],[357,277],[277,350],[350,452],[452,357],[357,350],[299,333],[333,297],[297,299],[396,175],[175,377],[377,396],[280,347],[347,330],[330,280],[269,303],[303,270],[270,269],[151,9],[9,337],[337,151],[344,278],[278,360],[360,344],[424,418],[418,
|
||||
431],[431,424],[270,304],[304,409],[409,270],[272,310],[310,407],[407,272],[322,270],[270,410],[410,322],[449,450],[450,347],[347,449],[432,422],[422,434],[434,432],[18,313],[313,17],[17,18],[291,306],[306,375],[375,291],[259,387],[387,260],[260,259],[424,335],[335,418],[418,424],[434,364],[364,416],[416,434],[391,423],[423,327],[327,391],[301,251],[251,298],[298,301],[275,281],[281,4],[4,275],[254,373],[373,253],[253,254],[375,307],[307,321],[321,375],[280,425],[425,411],[411,280],[200,421],[421,
|
||||
18],[18,200],[335,321],[321,406],[406,335],[321,320],[320,405],[405,321],[314,315],[315,17],[17,314],[423,426],[426,266],[266,423],[396,377],[377,369],[369,396],[270,322],[322,269],[269,270],[413,417],[417,464],[464,413],[385,386],[386,258],[258,385],[248,456],[456,419],[419,248],[298,284],[284,333],[333,298],[168,417],[417,8],[8,168],[448,346],[346,261],[261,448],[417,413],[413,285],[285,417],[326,327],[327,328],[328,326],[277,355],[355,329],[329,277],[309,392],[392,438],[438,309],[381,382],[382,
|
||||
256],[256,381],[279,429],[429,360],[360,279],[365,364],[364,379],[379,365],[355,277],[277,437],[437,355],[282,443],[443,283],[283,282],[281,275],[275,363],[363,281],[395,431],[431,369],[369,395],[299,297],[297,337],[337,299],[335,273],[273,321],[321,335],[348,450],[450,349],[349,348],[359,446],[446,467],[467,359],[283,293],[293,282],[282,283],[250,458],[458,462],[462,250],[300,276],[276,383],[383,300],[292,308],[308,325],[325,292],[283,276],[276,293],[293,283],[264,372],[372,447],[447,264],[346,352],
|
||||
[352,340],[340,346],[354,274],[274,19],[19,354],[363,456],[456,281],[281,363],[426,436],[436,425],[425,426],[380,381],[381,252],[252,380],[267,269],[269,393],[393,267],[421,200],[200,428],[428,421],[371,266],[266,329],[329,371],[432,287],[287,422],[422,432],[290,250],[250,328],[328,290],[385,258],[258,384],[384,385],[446,265],[265,342],[342,446],[386,387],[387,257],[257,386],[422,424],[424,430],[430,422],[445,342],[342,276],[276,445],[422,273],[273,424],[424,422],[306,292],[292,307],[307,306],[352,
|
||||
366],[366,345],[345,352],[268,271],[271,302],[302,268],[358,423],[423,371],[371,358],[327,294],[294,460],[460,327],[331,279],[279,294],[294,331],[303,271],[271,304],[304,303],[436,432],[432,427],[427,436],[304,272],[272,408],[408,304],[395,394],[394,431],[431,395],[378,395],[395,400],[400,378],[296,334],[334,299],[299,296],[6,351],[351,168],[168,6],[376,352],[352,411],[411,376],[307,325],[325,320],[320,307],[285,295],[295,336],[336,285],[320,319],[319,404],[404,320],[329,330],[330,349],[349,329],
|
||||
[334,293],[293,333],[333,334],[366,323],[323,447],[447,366],[316,15],[15,315],[315,316],[331,358],[358,279],[279,331],[317,14],[14,316],[316,317],[8,285],[285,9],[9,8],[277,329],[329,350],[350,277],[253,374],[374,252],[252,253],[319,318],[318,403],[403,319],[351,6],[6,419],[419,351],[324,318],[318,325],[325,324],[397,367],[367,365],[365,397],[288,435],[435,397],[397,288],[278,344],[344,439],[439,278],[310,272],[272,311],[311,310],[248,195],[195,281],[281,248],[375,273],[273,291],[291,375],[175,396],
|
||||
[396,199],[199,175],[312,311],[311,268],[268,312],[276,283],[283,445],[445,276],[390,373],[373,339],[339,390],[295,282],[282,296],[296,295],[448,449],[449,346],[346,448],[356,264],[264,454],[454,356],[337,336],[336,299],[299,337],[337,338],[338,151],[151,337],[294,278],[278,455],[455,294],[308,292],[292,415],[415,308],[429,358],[358,355],[355,429],[265,340],[340,372],[372,265],[352,346],[346,280],[280,352],[295,442],[442,282],[282,295],[354,19],[19,370],[370,354],[285,441],[441,295],[295,285],[195,
|
||||
248],[248,197],[197,195],[457,440],[440,274],[274,457],[301,300],[300,368],[368,301],[417,351],[351,465],[465,417],[251,301],[301,389],[389,251],[394,395],[395,379],[379,394],[399,412],[412,419],[419,399],[410,436],[436,322],[322,410],[326,2],[2,393],[393,326],[354,370],[370,461],[461,354],[393,164],[164,267],[267,393],[268,302],[302,12],[12,268],[312,268],[268,13],[13,312],[298,293],[293,301],[301,298],[265,446],[446,340],[340,265],[280,330],[330,425],[425,280],[322,426],[426,391],[391,322],[420,
|
||||
429],[429,437],[437,420],[393,391],[391,326],[326,393],[344,440],[440,438],[438,344],[458,459],[459,461],[461,458],[364,434],[434,394],[394,364],[428,396],[396,262],[262,428],[274,354],[354,457],[457,274],[317,316],[316,402],[402,317],[316,315],[315,403],[403,316],[315,314],[314,404],[404,315],[314,313],[313,405],[405,314],[313,421],[421,406],[406,313],[323,366],[366,361],[361,323],[292,306],[306,407],[407,292],[306,291],[291,408],[408,306],[291,287],[287,409],[409,291],[287,432],[432,410],[410,287],
|
||||
[427,434],[434,411],[411,427],[372,264],[264,383],[383,372],[459,309],[309,457],[457,459],[366,352],[352,401],[401,366],[1,274],[274,4],[4,1],[418,421],[421,262],[262,418],[331,294],[294,358],[358,331],[435,433],[433,367],[367,435],[392,289],[289,439],[439,392],[328,462],[462,326],[326,328],[94,2],[2,370],[370,94],[289,305],[305,455],[455,289],[339,254],[254,448],[448,339],[359,255],[255,446],[446,359],[254,253],[253,449],[449,254],[253,252],[252,450],[450,253],[252,256],[256,451],[451,252],[256,
|
||||
341],[341,452],[452,256],[414,413],[413,463],[463,414],[286,441],[441,414],[414,286],[286,258],[258,441],[441,286],[258,257],[257,442],[442,258],[257,259],[259,443],[443,257],[259,260],[260,444],[444,259],[260,467],[467,445],[445,260],[309,459],[459,250],[250,309],[305,289],[289,290],[290,305],[305,290],[290,460],[460,305],[401,376],[376,435],[435,401],[309,250],[250,392],[392,309],[376,411],[411,433],[433,376],[453,341],[341,464],[464,453],[357,453],[453,465],[465,357],[343,357],[357,412],[412,343],
|
||||
[437,343],[343,399],[399,437],[344,360],[360,440],[440,344],[420,437],[437,456],[456,420],[360,420],[420,363],[363,360],[361,401],[401,288],[288,361],[265,372],[372,353],[353,265],[390,339],[339,249],[249,390],[339,448],[448,255],[255,339]]);P("matrixDataToMatrix",function(a){for(var b=a.getCols(),c=a.getRows(),d=a.getPackedDataList(),e=[],g=0;g<c;g++)e.push(Array(b));for(g=0;g<c;g++)for(var f=0;f<b;f++){var h=1===a.getLayout()?g*b+f:f*c+g;e[g][f]=d[h]}return e});P("VERSION","0.4.1633559619");}).call(this);
|
||||
Binary file not shown.
@@ -0,0 +1,199 @@
|
||||
|
||||
var Module = typeof createMediapipeSolutionsPackedAssets !== 'undefined' ? createMediapipeSolutionsPackedAssets : {};
|
||||
|
||||
if (!Module.expectedDataFileDownloads) {
|
||||
Module.expectedDataFileDownloads = 0;
|
||||
}
|
||||
Module.expectedDataFileDownloads++;
|
||||
(function() {
|
||||
var loadPackage = function(metadata) {
|
||||
|
||||
var PACKAGE_PATH = '';
|
||||
if (typeof window === 'object') {
|
||||
PACKAGE_PATH = window['encodeURIComponent'](window.location.pathname.toString().substring(0, window.location.pathname.toString().lastIndexOf('/')) + '/');
|
||||
} else if (typeof process === 'undefined' && typeof location !== 'undefined') {
|
||||
// web worker
|
||||
PACKAGE_PATH = encodeURIComponent(location.pathname.toString().substring(0, location.pathname.toString().lastIndexOf('/')) + '/');
|
||||
}
|
||||
var PACKAGE_NAME = 'blaze-out/k8-opt/genfiles/third_party/mediapipe/web/solutions/face_mesh/face_mesh_solution_packed_assets.data';
|
||||
var REMOTE_PACKAGE_BASE = 'face_mesh_solution_packed_assets.data';
|
||||
if (typeof Module['locateFilePackage'] === 'function' && !Module['locateFile']) {
|
||||
Module['locateFile'] = Module['locateFilePackage'];
|
||||
err('warning: you defined Module.locateFilePackage, that has been renamed to Module.locateFile (using your locateFilePackage for now)');
|
||||
}
|
||||
var REMOTE_PACKAGE_NAME = Module['locateFile'] ? Module['locateFile'](REMOTE_PACKAGE_BASE, '') : REMOTE_PACKAGE_BASE;
|
||||
|
||||
var REMOTE_PACKAGE_SIZE = metadata['remote_package_size'];
|
||||
var PACKAGE_UUID = metadata['package_uuid'];
|
||||
|
||||
function fetchRemotePackage(packageName, packageSize, callback, errback) {
|
||||
|
||||
if (typeof process === 'object' && typeof process.versions === 'object' && typeof process.versions.node === 'string') {
|
||||
require('fs').readFile(packageName, function(err, contents) {
|
||||
if (err) {
|
||||
errback(err);
|
||||
} else {
|
||||
callback(contents.buffer);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', packageName, true);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onprogress = function(event) {
|
||||
var url = packageName;
|
||||
var size = packageSize;
|
||||
if (event.total) size = event.total;
|
||||
if (event.loaded) {
|
||||
if (!xhr.addedTotal) {
|
||||
xhr.addedTotal = true;
|
||||
if (!Module.dataFileDownloads) Module.dataFileDownloads = {};
|
||||
Module.dataFileDownloads[url] = {
|
||||
loaded: event.loaded,
|
||||
total: size
|
||||
};
|
||||
} else {
|
||||
Module.dataFileDownloads[url].loaded = event.loaded;
|
||||
}
|
||||
var total = 0;
|
||||
var loaded = 0;
|
||||
var num = 0;
|
||||
for (var download in Module.dataFileDownloads) {
|
||||
var data = Module.dataFileDownloads[download];
|
||||
total += data.total;
|
||||
loaded += data.loaded;
|
||||
num++;
|
||||
}
|
||||
total = Math.ceil(total * Module.expectedDataFileDownloads/num);
|
||||
if (Module['setStatus']) Module['setStatus']('Downloading data... (' + loaded + '/' + total + ')');
|
||||
} else if (!Module.dataFileDownloads) {
|
||||
if (Module['setStatus']) Module['setStatus']('Downloading data...');
|
||||
}
|
||||
};
|
||||
xhr.onerror = function(event) {
|
||||
throw new Error("NetworkError for: " + packageName);
|
||||
}
|
||||
xhr.onload = function(event) {
|
||||
if (xhr.status == 200 || xhr.status == 304 || xhr.status == 206 || (xhr.status == 0 && xhr.response)) { // file URLs can return 0
|
||||
var packageData = xhr.response;
|
||||
callback(packageData);
|
||||
} else {
|
||||
throw new Error(xhr.statusText + " : " + xhr.responseURL);
|
||||
}
|
||||
};
|
||||
xhr.send(null);
|
||||
};
|
||||
|
||||
function handleError(error) {
|
||||
console.error('package error:', error);
|
||||
};
|
||||
|
||||
var fetchedCallback = null;
|
||||
var fetched = Module['getPreloadedPackage'] ? Module['getPreloadedPackage'](REMOTE_PACKAGE_NAME, REMOTE_PACKAGE_SIZE) : null;
|
||||
|
||||
if (!fetched) fetchRemotePackage(REMOTE_PACKAGE_NAME, REMOTE_PACKAGE_SIZE, function(data) {
|
||||
if (fetchedCallback) {
|
||||
fetchedCallback(data);
|
||||
fetchedCallback = null;
|
||||
} else {
|
||||
fetched = data;
|
||||
}
|
||||
}, handleError);
|
||||
|
||||
function runWithFS() {
|
||||
|
||||
function assert(check, msg) {
|
||||
if (!check) throw msg + new Error().stack;
|
||||
}
|
||||
Module['FS_createPath']("/", "third_party", true, true);
|
||||
Module['FS_createPath']("/third_party", "mediapipe", true, true);
|
||||
Module['FS_createPath']("/third_party/mediapipe", "modules", true, true);
|
||||
Module['FS_createPath']("/third_party/mediapipe/modules", "face_landmark", true, true);
|
||||
Module['FS_createPath']("/third_party/mediapipe/modules", "face_geometry", true, true);
|
||||
Module['FS_createPath']("/third_party/mediapipe/modules/face_geometry", "data", true, true);
|
||||
Module['FS_createPath']("/third_party/mediapipe/modules", "face_detection", true, true);
|
||||
|
||||
/** @constructor */
|
||||
function DataRequest(start, end, audio) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.audio = audio;
|
||||
}
|
||||
DataRequest.prototype = {
|
||||
requests: {},
|
||||
open: function(mode, name) {
|
||||
this.name = name;
|
||||
this.requests[name] = this;
|
||||
Module['addRunDependency']('fp ' + this.name);
|
||||
},
|
||||
send: function() {},
|
||||
onload: function() {
|
||||
var byteArray = this.byteArray.subarray(this.start, this.end);
|
||||
this.finish(byteArray);
|
||||
},
|
||||
finish: function(byteArray) {
|
||||
var that = this;
|
||||
|
||||
Module['FS_createPreloadedFile'](this.name, null, byteArray, true, true, function() {
|
||||
Module['removeRunDependency']('fp ' + that.name);
|
||||
}, function() {
|
||||
if (that.audio) {
|
||||
Module['removeRunDependency']('fp ' + that.name); // workaround for chromium bug 124926 (still no audio with this, but at least we don't hang)
|
||||
} else {
|
||||
err('Preloading file ' + that.name + ' failed');
|
||||
}
|
||||
}, false, true); // canOwn this data in the filesystem, it is a slide into the heap that will never change
|
||||
|
||||
this.requests[this.name] = null;
|
||||
}
|
||||
};
|
||||
|
||||
var files = metadata['files'];
|
||||
for (var i = 0; i < files.length; ++i) {
|
||||
new DataRequest(files[i]['start'], files[i]['end'], files[i]['audio']).open('GET', files[i]['filename']);
|
||||
}
|
||||
|
||||
|
||||
function processPackageData(arrayBuffer) {
|
||||
assert(arrayBuffer, 'Loading data file failed.');
|
||||
assert(arrayBuffer instanceof ArrayBuffer, 'bad input to processPackageData');
|
||||
var byteArray = new Uint8Array(arrayBuffer);
|
||||
var curr;
|
||||
|
||||
// Reuse the bytearray from the XHR as the source for file reads.
|
||||
DataRequest.prototype.byteArray = byteArray;
|
||||
|
||||
var files = metadata['files'];
|
||||
for (var i = 0; i < files.length; ++i) {
|
||||
DataRequest.prototype.requests[files[i].filename].onload();
|
||||
}
|
||||
Module['removeRunDependency']('datafile_blaze-out/k8-opt/genfiles/third_party/mediapipe/web/solutions/face_mesh/face_mesh_solution_packed_assets.data');
|
||||
|
||||
};
|
||||
Module['addRunDependency']('datafile_blaze-out/k8-opt/genfiles/third_party/mediapipe/web/solutions/face_mesh/face_mesh_solution_packed_assets.data');
|
||||
|
||||
if (!Module.preloadResults) Module.preloadResults = {};
|
||||
|
||||
Module.preloadResults[PACKAGE_NAME] = {fromCache: false};
|
||||
if (fetched) {
|
||||
processPackageData(fetched);
|
||||
fetched = null;
|
||||
} else {
|
||||
fetchedCallback = processPackageData;
|
||||
}
|
||||
|
||||
}
|
||||
if (Module['calledRun']) {
|
||||
runWithFS();
|
||||
} else {
|
||||
if (!Module['preRun']) Module['preRun'] = [];
|
||||
Module["preRun"].push(runWithFS); // FS is not initialized yet, wait for it
|
||||
}
|
||||
|
||||
}
|
||||
loadPackage({"files": [{"filename": "/third_party/mediapipe/modules/face_landmark/face_landmark_with_attention.tflite", "start": 0, "end": 2495952, "audio": 0}, {"filename": "/third_party/mediapipe/modules/face_landmark/face_landmark.tflite", "start": 2495952, "end": 3737848, "audio": 0}, {"filename": "/third_party/mediapipe/modules/face_geometry/data/geometry_pipeline_metadata_landmarks.binarypb", "start": 3737848, "end": 3757224, "audio": 0}, {"filename": "/third_party/mediapipe/modules/face_detection/face_detection_short_range.tflite", "start": 3757224, "end": 3986256, "audio": 0}], "remote_package_size": 3986256, "package_uuid": "f5f855ab-ba1b-4fdf-8b0a-77c2c611502f"});
|
||||
|
||||
})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Vendored
+31
File diff suppressed because one or more lines are too long
@@ -1,5 +1,7 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
contactEmail: 'info@canticristiani.it',
|
||||
appName: 'CantiCristiani'
|
||||
appName: 'CantiCristiani',
|
||||
apiAuthUser: 'canti',
|
||||
apiAuthPass: 'antani2026'
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
contactEmail: 'info@canticristiani.it',
|
||||
appName: 'CantiCristiani'
|
||||
appName: 'CantiCristiani',
|
||||
apiAuthUser: 'canti',
|
||||
apiAuthPass: 'antani2026'
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
+126
-3
@@ -33,8 +33,8 @@
|
||||
*/
|
||||
|
||||
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
|
||||
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
|
||||
@import "@ionic/angular/css/palettes/dark.system.css";
|
||||
/* @import "@ionic/angular/css/palettes/dark.system.css"; */
|
||||
@import "@ionic/angular/css/palettes/dark.class.css";
|
||||
|
||||
ion-header {
|
||||
border: none !important;
|
||||
@@ -66,14 +66,124 @@ ion-app {
|
||||
}
|
||||
|
||||
/* High Contrast Mode Overrides */
|
||||
html.high-contrast, body.high-contrast {
|
||||
color-scheme: light !important;
|
||||
}
|
||||
|
||||
body.high-contrast {
|
||||
--ion-background-color: #ffffff;
|
||||
--ion-background-color-rgb: 255, 255, 255;
|
||||
--ion-text-color: #000000;
|
||||
--ion-text-color-rgb: 0, 0, 0;
|
||||
|
||||
/* Primary color - complete set for shadow DOM components */
|
||||
--ion-color-primary: #000000;
|
||||
--ion-color-secondary: #e67e22; // A bit darker for readability on white
|
||||
--ion-color-primary-rgb: 0, 0, 0;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #000000;
|
||||
--ion-color-primary-tint: #1a1a1a;
|
||||
|
||||
/* Secondary color - complete set for shadow DOM components */
|
||||
--ion-color-secondary: #e67e22;
|
||||
--ion-color-secondary-rgb: 230, 126, 34;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #cb6f1e;
|
||||
--ion-color-secondary-tint: #e98b38;
|
||||
|
||||
/* Medium color - used by filter chips */
|
||||
--ion-color-medium: #92949c;
|
||||
--ion-color-medium-rgb: 146, 148, 156;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-medium-shade: #808289;
|
||||
--ion-color-medium-tint: #9d9fa6;
|
||||
|
||||
/* Light color */
|
||||
--ion-color-light: #f4f5f8;
|
||||
--ion-color-light-rgb: 244, 245, 248;
|
||||
--ion-color-light-contrast: #000000;
|
||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-light-shade: #d7d8da;
|
||||
--ion-color-light-tint: #f5f6f9;
|
||||
|
||||
/* Dark color */
|
||||
--ion-color-dark: #222428;
|
||||
--ion-color-dark-rgb: 34, 36, 40;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-dark-shade: #1e2023;
|
||||
--ion-color-dark-tint: #383a3e;
|
||||
|
||||
/* Light mode background step variables (light → dark) */
|
||||
--ion-background-color-step-50: #f2f2f2;
|
||||
--ion-background-color-step-100: #e6e6e6;
|
||||
--ion-background-color-step-150: #d9d9d9;
|
||||
--ion-background-color-step-200: #cccccc;
|
||||
--ion-background-color-step-250: #bfbfbf;
|
||||
--ion-background-color-step-300: #b3b3b3;
|
||||
--ion-background-color-step-350: #a6a6a6;
|
||||
--ion-background-color-step-400: #999999;
|
||||
--ion-background-color-step-450: #8c8c8c;
|
||||
--ion-background-color-step-500: #808080;
|
||||
--ion-background-color-step-550: #737373;
|
||||
--ion-background-color-step-600: #666666;
|
||||
--ion-background-color-step-650: #595959;
|
||||
--ion-background-color-step-700: #4d4d4d;
|
||||
--ion-background-color-step-750: #404040;
|
||||
--ion-background-color-step-800: #333333;
|
||||
--ion-background-color-step-850: #262626;
|
||||
--ion-background-color-step-900: #1a1a1a;
|
||||
--ion-background-color-step-950: #0d0d0d;
|
||||
|
||||
/* Light mode text step variables (dark → light) */
|
||||
--ion-text-color-step-50: #0d0d0d;
|
||||
--ion-text-color-step-100: #1a1a1a;
|
||||
--ion-text-color-step-150: #262626;
|
||||
--ion-text-color-step-200: #333333;
|
||||
--ion-text-color-step-250: #404040;
|
||||
--ion-text-color-step-300: #4d4d4d;
|
||||
--ion-text-color-step-350: #595959;
|
||||
--ion-text-color-step-400: #666666;
|
||||
--ion-text-color-step-450: #737373;
|
||||
--ion-text-color-step-500: #808080;
|
||||
--ion-text-color-step-550: #8c8c8c;
|
||||
--ion-text-color-step-600: #999999;
|
||||
--ion-text-color-step-650: #a6a6a6;
|
||||
--ion-text-color-step-700: #b3b3b3;
|
||||
--ion-text-color-step-750: #bfbfbf;
|
||||
--ion-text-color-step-800: #cccccc;
|
||||
--ion-text-color-step-850: #d9d9d9;
|
||||
--ion-text-color-step-900: #e6e6e6;
|
||||
--ion-text-color-step-950: #f2f2f2;
|
||||
|
||||
/* Legacy step variables (for older Ionic components) */
|
||||
--ion-color-step-50: #f4f5f8;
|
||||
--ion-color-step-100: #e0e0e0;
|
||||
--ion-color-step-150: #dcdcdc;
|
||||
--ion-color-step-200: #cccccc;
|
||||
--ion-color-step-250: #bfbfbf;
|
||||
--ion-color-step-300: #b3b3b3;
|
||||
--ion-color-step-350: #a6a6a6;
|
||||
--ion-color-step-400: #999999;
|
||||
--ion-color-step-450: #8c8c8c;
|
||||
--ion-color-step-500: #808080;
|
||||
--ion-color-step-550: #737373;
|
||||
--ion-color-step-600: #666666;
|
||||
--ion-color-step-650: #595959;
|
||||
--ion-color-step-700: #4d4d4d;
|
||||
--ion-color-step-750: #404040;
|
||||
--ion-color-step-800: #333333;
|
||||
--ion-color-step-850: #262626;
|
||||
--ion-color-step-900: #191919;
|
||||
--ion-color-step-950: #0d0d0d;
|
||||
|
||||
/* Reset component-specific dark mode variables */
|
||||
--ion-item-background: #ffffff;
|
||||
--ion-card-background: #ffffff;
|
||||
--ion-toolbar-background: #ffffff;
|
||||
--ion-tab-bar-background: #ffffff;
|
||||
|
||||
.bg-gradient {
|
||||
background: #ffffff !important;
|
||||
@@ -263,6 +373,19 @@ body.high-contrast {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent button color/background issues in active/focus/hover states in high contrast */
|
||||
ion-button {
|
||||
--color-activated: var(--color) !important;
|
||||
--color-focused: var(--color) !important;
|
||||
--color-hover: var(--color) !important;
|
||||
|
||||
&[fill="clear"], &[fill="outline"] {
|
||||
--background-activated: rgba(0, 0, 0, 0.1) !important;
|
||||
--background-focused: rgba(0, 0, 0, 0.08) !important;
|
||||
--background-hover: rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.offline-badge-header {
|
||||
|
||||
+174
-1
@@ -1,12 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="it" class="notranslate">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="google" content="notranslate"/>
|
||||
<title>CantiCristiani</title>
|
||||
|
||||
<base href="/"/>
|
||||
|
||||
<!-- SEO & OpenGraph Meta Tags -->
|
||||
<meta name="description" content="Testi e accordi di canti cristiani e liturgici, sempre con te anche offline." />
|
||||
<meta property="og:title" content="Canti Cristiani" />
|
||||
<meta property="og:description" content="Testi e accordi di canti cristiani e liturgici, sempre con te anche offline." />
|
||||
<meta property="og:image" content="assets/icon/favicon.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.canticristiani.it/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark"/>
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
@@ -26,8 +35,172 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="pwa-boot-loader" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif; transition: opacity 0.5s ease;">
|
||||
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
|
||||
<div style="margin-bottom: 25px; display: inline-block;">
|
||||
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||
</div>
|
||||
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
|
||||
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
|
||||
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
|
||||
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
|
||||
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
|
||||
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
|
||||
</div>
|
||||
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-root></app-root>
|
||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// 1. Loader API definition
|
||||
window.PwaLoader = {
|
||||
update: function(options) {
|
||||
const titleEl = document.getElementById('pwa-boot-title');
|
||||
const descEl = document.getElementById('pwa-boot-desc');
|
||||
const phaseEl = document.getElementById('pwa-boot-phase');
|
||||
const bar = document.getElementById('pwa-boot-bar');
|
||||
const pctText = document.getElementById('pwa-boot-percent');
|
||||
const versionEl = document.getElementById('pwa-boot-version');
|
||||
|
||||
if (options.title && titleEl) titleEl.textContent = options.title;
|
||||
if (options.desc && descEl) descEl.textContent = options.desc;
|
||||
if (options.phase && phaseEl) phaseEl.textContent = options.phase;
|
||||
if (options.version && versionEl) versionEl.textContent = options.version;
|
||||
|
||||
if (options.percent !== undefined) {
|
||||
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
if (bar && bar.parentElement) bar.parentElement.style.display = 'block';
|
||||
if (pctText) {
|
||||
pctText.style.display = 'block';
|
||||
pctText.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isRedirect) {
|
||||
if (bar && bar.parentElement) bar.parentElement.style.display = 'none';
|
||||
if (pctText) pctText.style.display = 'none';
|
||||
if (phaseEl) phaseEl.style.display = 'none';
|
||||
if (versionEl) versionEl.style.display = 'none';
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Chiudi il browser è stata installata la app sul device';
|
||||
titleEl.style.fontSize = '1.4rem';
|
||||
titleEl.style.lineHeight = '1.5';
|
||||
}
|
||||
if (descEl) descEl.style.display = 'none';
|
||||
}
|
||||
},
|
||||
hide: function() {
|
||||
const loader = document.getElementById('pwa-boot-loader');
|
||||
if (loader) {
|
||||
loader.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (loader.parentNode) {
|
||||
loader.parentNode.removeChild(loader);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
show: function() {
|
||||
let loader = document.getElementById('pwa-boot-loader');
|
||||
if (!loader) {
|
||||
loader = document.createElement('div');
|
||||
loader.id = 'pwa-boot-loader';
|
||||
loader.style.position = 'fixed';
|
||||
loader.style.top = '0';
|
||||
loader.style.left = '0';
|
||||
loader.style.width = '100vw';
|
||||
loader.style.height = '100vh';
|
||||
loader.style.background = 'radial-gradient(circle at top left, #2d3436 0%, #121212 100%)';
|
||||
loader.style.color = '#ffffff';
|
||||
loader.style.display = 'flex';
|
||||
loader.style.flexDirection = 'column';
|
||||
loader.style.justifyContent = 'center';
|
||||
loader.style.alignItems = 'center';
|
||||
loader.style.zIndex = '99999';
|
||||
loader.style.fontFamily = "'Outfit', sans-serif";
|
||||
loader.style.transition = 'opacity 0.5s ease';
|
||||
loader.style.opacity = '0';
|
||||
|
||||
loader.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
|
||||
<div style="margin-bottom: 25px; display: inline-block;">
|
||||
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||
</div>
|
||||
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
|
||||
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
|
||||
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
|
||||
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
|
||||
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
|
||||
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
|
||||
</div>
|
||||
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(loader);
|
||||
loader.offsetHeight; // Force reflow
|
||||
loader.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Setup initial labels
|
||||
const isUpdate = window.location.search.includes('update');
|
||||
const titleEl = document.getElementById('pwa-boot-title');
|
||||
const descEl = document.getElementById('pwa-boot-desc');
|
||||
const phaseEl = document.getElementById('pwa-boot-phase');
|
||||
|
||||
const isFirstInstall = 'serviceWorker' in navigator && !navigator.serviceWorker.controller;
|
||||
|
||||
if (isUpdate) {
|
||||
if (titleEl) titleEl.textContent = 'Aggiornamento completato';
|
||||
if (descEl) descEl.textContent = 'Ottimizzazione e avvio della nuova versione...';
|
||||
if (phaseEl) phaseEl.textContent = 'Fase: Avvio';
|
||||
} else if (isFirstInstall) {
|
||||
if (titleEl) titleEl.textContent = 'Download in corso';
|
||||
if (descEl) descEl.textContent = 'Scaricamento dei componenti dell\'applicazione...';
|
||||
if (phaseEl) phaseEl.textContent = 'Fase: Download';
|
||||
}
|
||||
|
||||
const versionEl = document.getElementById('pwa-boot-version');
|
||||
fetch('/version.json?cb=' + Date.now())
|
||||
.then(res => {
|
||||
if (res.ok) return res.json();
|
||||
throw new Error('Fallback');
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.version && versionEl) {
|
||||
versionEl.textContent = 'Versione ' + data.version;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (versionEl) {
|
||||
versionEl.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Start default progressive animation
|
||||
let percent = 0;
|
||||
const bar = document.getElementById('pwa-boot-bar');
|
||||
const pctText = document.getElementById('pwa-boot-percent');
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Only auto-advance if we are in the initial boot/install phase and not hijacked by Angular
|
||||
if (percent < 90) {
|
||||
percent += Math.floor(Math.random() * 8) + 3;
|
||||
if (percent > 90) percent = 90;
|
||||
if (bar) bar.style.width = percent + '%';
|
||||
if (pctText) pctText.textContent = percent + '%';
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 80);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
+22
@@ -8,5 +8,27 @@ if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
// Patch window.fetch to automatically inject basic auth header for api.canticristiani.it
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url);
|
||||
if (url.includes('api.canticristiani.it')) {
|
||||
init = init || {};
|
||||
init.headers = init.headers || {};
|
||||
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.set('Authorization', authHeader);
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
const hasAuth = init.headers.some(([key]) => key.toLowerCase() === 'authorization');
|
||||
if (!hasAuth) {
|
||||
init.headers.push(['Authorization', authHeader]);
|
||||
}
|
||||
} else {
|
||||
(init.headers as Record<string, string>)['Authorization'] = authHeader;
|
||||
}
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
|
||||
Reference in New Issue
Block a user