Compare commits
15 Commits
97a11d5074
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 171780bf87 | |||
| a15a6c2139 | |||
| 0069c1a55f | |||
| ad9286002b | |||
| 73097bf9bd | |||
| 3c91355e10 | |||
| 94d014f815 | |||
| 9f9b164937 | |||
| 333858bf48 | |||
| 2607c9c1a8 | |||
| 68d455289d | |||
| d4a52a8fbf | |||
| 6ed90ffff1 | |||
| b0bb5735c8 | |||
| 93385097f2 |
@@ -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.
|
||||||
+31
-18
@@ -29,25 +29,38 @@ if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Rotazione file su FTP ---
|
# --- Rotazione e Caricamento file (FTP o VPS) ---
|
||||||
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
if [ -n "$VPS_HOST" ]; then
|
||||||
# Rinominiamo canti.json in canti_ex.json.
|
echo "🔄 Rotazione file su VPS ($VPS_HOST)..."
|
||||||
# Usiamo i percorsi assoluti per sicurezza.
|
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"
|
||||||
curl -s -u "$FTP_USER:$FTP_PASS" \
|
|
||||||
--ftp-pasv \
|
echo "🚀 Caricamento nuovo file su VPS via SCP..."
|
||||||
-Q "*DELE /htdocs/api/canti_ex.json" \
|
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||||
-Q "*RNFR /htdocs/api/canti.json" \
|
echo "✅ Allineamento su VPS completato con successo!"
|
||||||
-Q "*RNTO /htdocs/api/canti_ex.json" \
|
else
|
||||||
"ftp://$FTP_HOST/" > /dev/null
|
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
# --- Upload via FTP ---
|
exit 1
|
||||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
fi
|
||||||
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
|
else
|
||||||
echo "❌ Errore durante il caricamento FTP."
|
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
||||||
rm "$TEMP_FILE"
|
# Rinominiamo canti.json in canti_ex.json.
|
||||||
exit 1
|
# Usiamo i percorsi assoluti per sicurezza.
|
||||||
|
curl -s -u "$FTP_USER:$FTP_PASS" \
|
||||||
|
--ftp-pasv \
|
||||||
|
-Q "*DELE /htdocs/api/canti_ex.json" \
|
||||||
|
-Q "*RNFR /htdocs/api/canti.json" \
|
||||||
|
-Q "*RNTO /htdocs/api/canti_ex.json" \
|
||||||
|
"ftp://$FTP_HOST/" > /dev/null
|
||||||
|
|
||||||
|
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||||
|
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su FTP completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento FTP."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|||||||
+19
-7
@@ -29,14 +29,26 @@ if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Upload via FTP ---
|
# --- Caricamento file (FTP o VPS) ---
|
||||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
if [ -n "$VPS_HOST" ]; then
|
||||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
echo "🚀 Caricamento nuovo file su VPS ($VPS_HOST) via SCP..."
|
||||||
echo "✅ Allineamento completato con successo!"
|
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
|
else
|
||||||
echo "❌ Errore durante il caricamento FTP."
|
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||||
rm "$TEMP_FILE"
|
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||||
exit 1
|
echo "✅ Allineamento su FTP completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento FTP."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|||||||
+2
-2
@@ -57,8 +57,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "16kb",
|
"maximumWarning": "32kb",
|
||||||
"maximumError": "25kb"
|
"maximumError": "48kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
|
|||||||
+13
-39
@@ -8,41 +8,14 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Configurazione Target e Parallelismo ---
|
# --- Configurazione Target ---
|
||||||
TARGET="tophost"
|
TARGET="contabo"
|
||||||
THREADS=""
|
|
||||||
|
|
||||||
if [ "$1" = "contabo" ] || [ "$1" = "tophost" ]; then
|
|
||||||
TARGET="$1"
|
|
||||||
THREADS="$2"
|
|
||||||
else
|
|
||||||
# Se il primo argomento è un numero, indica il numero di thread per tophost
|
|
||||||
if [[ "$1" =~ ^[0-9]+$ ]]; then
|
|
||||||
THREADS="$1"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
THREADS=${THREADS:-${FTP_THREADS:-100}}
|
|
||||||
export FTP_THREADS=$THREADS
|
|
||||||
|
|
||||||
echo "🎯 Target di deploy: $TARGET"
|
echo "🎯 Target di deploy: $TARGET"
|
||||||
|
|
||||||
# --- Validazione configurazione basata sul target ---
|
# --- Validazione configurazione ---
|
||||||
if [ "$TARGET" = "tophost" ]; then
|
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
|
||||||
FTP_HOST="ftp.canticristiani.it"
|
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
|
||||||
FTP_USER="canticristiani.it"
|
exit 1
|
||||||
FTP_PASS="$FTP_PASSWORD"
|
|
||||||
REMOTE_DIR=""
|
|
||||||
|
|
||||||
if [ -z "$FTP_PASS" ]; then
|
|
||||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
|
|
||||||
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Aggiornamento Versione ---
|
# --- Aggiornamento Versione ---
|
||||||
@@ -84,10 +57,11 @@ echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.j
|
|||||||
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||||
|
|
||||||
# --- Upload ---
|
# --- Upload ---
|
||||||
if [ "$TARGET" = "tophost" ]; then
|
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||||
echo "🚀 Upload via FTP su Tophost in corso..."
|
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||||
python3 scratch/deploy_ftp.py
|
|
||||||
else
|
# --- Allineamento Cantiletture JSON ---
|
||||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
if [ -f "./allinealetture.sh" ]; then
|
||||||
rsync -avz --delete www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
echo "🔄 Sincronizzazione cantiletture.json su server..."
|
||||||
|
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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à. |
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"theme_color": "#3880ff",
|
"theme_color": "#3880ff",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "focus-existing"
|
||||||
|
},
|
||||||
"protocol_handlers": [
|
"protocol_handlers": [
|
||||||
{
|
{
|
||||||
"protocol": "web+canti",
|
"protocol": "web+canti",
|
||||||
|
|||||||
@@ -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));
|
||||||
+68
-51
@@ -4,82 +4,99 @@
|
|||||||
|
|
||||||
<!-- PWA Redirect Overlay -->
|
<!-- PWA Redirect Overlay -->
|
||||||
<div *ngIf="showRedirectOverlay()"
|
<div *ngIf="showRedirectOverlay()"
|
||||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
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="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;">
|
<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);">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Fase di redirect normale (in attesa di apertura) -->
|
<!-- Se la PWA è rilevata come installata -->
|
||||||
<ng-container *ngIf="!redirectFailed()">
|
<ng-container *ngIf="isPwaInstalled()">
|
||||||
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; color: #ffffff; -webkit-font-smoothing: antialiased;">Applicazione Installata</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;">
|
<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;">
|
||||||
Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
|
La app risulta già installata sul dispositivo, chiudi il browser ed usa quella oppure disinstallala se preferisci utilizzarla da qui.
|
||||||
</p>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
<button (click)="openPwaManual()"
|
<button (click)="stayInBrowserForceUninstallCheck()"
|
||||||
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);">
|
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%;">
|
||||||
Apri Manualmente
|
Continua nel browser (L'ho disinstallata)
|
||||||
</button>
|
|
||||||
<button (click)="stayInBrowser()"
|
|
||||||
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
|
|
||||||
Rimani nel browser
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Se l'apertura automatica/manuale fallisce (PWA probabilmente non installata) -->
|
<!-- Se l'applicazione NON è installata (flusso normale di redirect / installazione guidata) -->
|
||||||
<ng-container *ngIf="redirectFailed()">
|
<ng-container *ngIf="!isPwaInstalled()">
|
||||||
<!-- iOS Guide -->
|
<!-- Fase di redirect normale (in attesa di apertura) -->
|
||||||
<div *ngIf="settingsService.isIos()" style="text-align: left;">
|
<ng-container *ngIf="!redirectFailed()">
|
||||||
<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>
|
<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.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;">
|
<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!
|
Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
|
||||||
</p>
|
</p>
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
<button *ngIf="settingsService.showInstallButton() || settingsService.deferredPrompt()" (click)="triggerInstallFromRedirect()"
|
<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);">
|
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
|
Apri Manualmente
|
||||||
</button>
|
</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()"
|
<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%;">
|
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
|
Continua nel browser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PWA Install Overlay -->
|
<!-- PWA Install Overlay -->
|
||||||
<div *ngIf="showInstallOverlay()"
|
<div *ngIf="showInstallOverlay()"
|
||||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
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 -->
|
<!-- Android Install Prompt -->
|
||||||
<div *ngIf="settingsService.isAndroid()"
|
<div *ngIf="settingsService.isAndroid()"
|
||||||
|
|||||||
+457
-128
@@ -1,12 +1,87 @@
|
|||||||
import { Component, inject, OnInit, signal, effect } from '@angular/core';
|
import { Component, inject, NgZone, OnInit, signal, effect } from '@angular/core';
|
||||||
import { ThemeService } from './services/theme.service';
|
import { ThemeService } from './services/theme.service';
|
||||||
import { CantiService } from './services/canti.service';
|
import { CantiService } from './services/canti.service';
|
||||||
import { SettingsService } from './services/settings.service';
|
import { SettingsService } from './services/settings.service';
|
||||||
import { VERSION } from './version';
|
import { VERSION } from './version';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute, NavigationStart } from '@angular/router';
|
||||||
import { ToastController } from '@ionic/angular';
|
import { ToastController, Platform, AlertController, NavController } from '@ionic/angular';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||||
import { filter, first } from 'rxjs/operators';
|
import { filter, first } from 'rxjs/operators';
|
||||||
|
import { 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({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -19,21 +94,105 @@ export class AppComponent implements OnInit {
|
|||||||
public cantiService = inject(CantiService);
|
public cantiService = inject(CantiService);
|
||||||
public settingsService = inject(SettingsService);
|
public settingsService = inject(SettingsService);
|
||||||
public version = VERSION;
|
public version = VERSION;
|
||||||
|
private platform = inject(Platform);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private location = inject(Location);
|
||||||
|
private navCtrl = inject(NavController);
|
||||||
private toastCtrl = inject(ToastController);
|
private toastCtrl = inject(ToastController);
|
||||||
|
private alertCtrl = inject(AlertController);
|
||||||
private swUpdate = inject(SwUpdate);
|
private swUpdate = inject(SwUpdate);
|
||||||
|
private ngZone = inject(NgZone);
|
||||||
|
|
||||||
public showRedirectOverlay = signal<boolean>(false);
|
public showRedirectOverlay = signal<boolean>(false);
|
||||||
public showInstallOverlay = 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 redirectFailed = signal<boolean>(false);
|
||||||
public protocolLink = '';
|
public protocolLink = '';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Gli aggiornamenti automatici e periodici sono stati rimossi.
|
// Gli aggiornamenti automatici e periodici sono stati rimossi.
|
||||||
// L'aggiornamento viene gestito esclusivamente in modo manuale
|
// L'aggiornamento viene gestito esclusivamente in modo manuale
|
||||||
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
|
// 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(() => {
|
effect(() => {
|
||||||
this.checkLoaderDismissal();
|
this.checkLoaderDismissal();
|
||||||
});
|
});
|
||||||
@@ -45,19 +204,59 @@ export class AppComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se l'overlay di redirect o di installazione è mostrato, NON nascondiamo il loader iniziale
|
// Se stiamo attivamente installando o reindirizzando, NON nascondiamo il loader
|
||||||
// per rimanere nella welcome page mentre l'utente sceglie.
|
if (this.isInstalling() || this.isRedirecting()) {
|
||||||
if (this.showRedirectOverlay() || this.showInstallOverlay()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Altrimenti, nascondiamo il loader per far entrare l'utente nell'app
|
// Altrimenti, nascondiamo il loader per far entrare l'utente
|
||||||
if ((window as any).PwaLoader) {
|
if ((window as any).PwaLoader) {
|
||||||
(window as any).PwaLoader.hide();
|
(window as any).PwaLoader.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
|
// Gestione del tasto back (PWA/Browser e Nativo/Hardware) secondo le 3 specifiche:
|
||||||
|
// 1- Se siamo su un canto (/player o /display), premendo back andiamo sempre sulla home.
|
||||||
|
// 2- Se siamo sulla home (/home o /), premendo back l'app deve uscire.
|
||||||
|
// 3- Per tutto il resto, segue la logica standard andando alla pagina precedente nello storico.
|
||||||
|
|
||||||
|
// Gestione popstate (tasto indietro browser / gesture PWA)
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((e): e is NavigationStart => e instanceof NavigationStart),
|
||||||
|
filter(e => e.navigationTrigger === 'popstate')
|
||||||
|
).subscribe(() => {
|
||||||
|
const currentPath = this.router.url.split('?')[0];
|
||||||
|
if (currentPath === '/home' || currentPath === '/') {
|
||||||
|
// Spec 2: Sulla home, usciamo dall'app
|
||||||
|
this.router.navigate(['/home'], { replaceUrl: true });
|
||||||
|
this.exitApp();
|
||||||
|
} else if (currentPath === '/player' || currentPath === '/display') {
|
||||||
|
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
|
||||||
|
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||||
|
}
|
||||||
|
// Spec 3: Per il resto (es. /settings), popstate prosegue normalmente verso la pagina precedente
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestione tasto indietro hardware (es. Android / Capacitor / PWA)
|
||||||
|
this.platform.backButton.subscribeWithPriority(10, async () => {
|
||||||
|
const currentPath = this.router.url.split('?')[0];
|
||||||
|
if (currentPath === '/home' || currentPath === '/') {
|
||||||
|
// Spec 2: Sulla home, usciamo dall'app
|
||||||
|
this.exitApp();
|
||||||
|
} else if (currentPath === '/player' || currentPath === '/display') {
|
||||||
|
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
|
||||||
|
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||||
|
} else {
|
||||||
|
// Spec 3: Per il resto, logica standard (pagina precedente)
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
this.location.back();
|
||||||
|
} else {
|
||||||
|
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add global horizontal scroll support for wheel on horizontal containers
|
// Add global horizontal scroll support for wheel on horizontal containers
|
||||||
window.addEventListener('wheel', (event: WheelEvent) => {
|
window.addEventListener('wheel', (event: WheelEvent) => {
|
||||||
if (Math.abs(event.deltaY) > 0 && Math.abs(event.deltaX) === 0) {
|
if (Math.abs(event.deltaY) > 0 && Math.abs(event.deltaX) === 0) {
|
||||||
@@ -95,6 +294,15 @@ export class AppComponent implements OnInit {
|
|||||||
// Set signal indicating startup version check is complete
|
// Set signal indicating startup version check is complete
|
||||||
this.settingsService.isVersionCheckComplete.set(true);
|
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 => {
|
this.route.queryParams.subscribe(params => {
|
||||||
const protocolUrl = params['url'];
|
const protocolUrl = params['url'];
|
||||||
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||||
@@ -120,14 +328,123 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
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();
|
this.checkAndRedirectToPwa();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLaunchUrl(urlStr: string) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(urlStr);
|
||||||
|
|
||||||
|
// 1. Check if it's a protocol link inside query params (e.g. /?url=web+canti://...)
|
||||||
|
const protocolUrl = urlObj.searchParams.get('url');
|
||||||
|
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||||
|
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
|
||||||
|
const innerUrlObj = new URL(cleanUrl);
|
||||||
|
|
||||||
|
let targetPath = innerUrlObj.pathname;
|
||||||
|
if (targetPath === '/open' || targetPath === '//open') {
|
||||||
|
targetPath = '/';
|
||||||
|
} else if (targetPath.startsWith('/open/')) {
|
||||||
|
targetPath = targetPath.substring(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams: any = {};
|
||||||
|
innerUrlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AppComponent] Launch queue routing (protocol) to:', targetPath, queryParams);
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Otherwise, route directly to the pathname and query params of the URL
|
||||||
|
let targetPath = urlObj.pathname;
|
||||||
|
const queryParams: any = {};
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AppComponent] Launch queue routing (direct) to:', targetPath, queryParams);
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse launch url:', urlStr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async checkVersionSync(): Promise<boolean> {
|
async checkVersionSync(): Promise<boolean> {
|
||||||
// Controlla SEMPRE version.json per primo — è il modo più affidabile per
|
// Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
|
||||||
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
|
// Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
|
||||||
// Su mobile, checkForUpdate() può essere lento o inaffidabile.
|
|
||||||
try {
|
try {
|
||||||
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
|
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
|
||||||
const response = await Promise.race([
|
const response = await Promise.race([
|
||||||
@@ -137,10 +454,9 @@ export class AppComponent implements OnInit {
|
|||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && data.version && data.version !== VERSION) {
|
if (data && data.version && data.version !== VERSION) {
|
||||||
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Forza aggiornamento...`);
|
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Avvio aggiornamento automatico...`);
|
||||||
const overlay = showFullscreenUpdateOverlay();
|
|
||||||
|
|
||||||
// Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW)
|
// Scarica e attiva il nuovo Service Worker se abilitato
|
||||||
if (this.swUpdate.isEnabled) {
|
if (this.swUpdate.isEnabled) {
|
||||||
try {
|
try {
|
||||||
const hasSwUpdate = await Promise.race([
|
const hasSwUpdate = await Promise.race([
|
||||||
@@ -148,7 +464,7 @@ export class AppComponent implements OnInit {
|
|||||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
||||||
]);
|
]);
|
||||||
if (hasSwUpdate) {
|
if (hasSwUpdate) {
|
||||||
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...');
|
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...');
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.swUpdate.activateUpdate(),
|
this.swUpdate.activateUpdate(),
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, 5000))
|
new Promise<void>((resolve) => setTimeout(resolve, 5000))
|
||||||
@@ -159,7 +475,7 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggiorna anche la registrazione SW direttamente (doppia sicurezza)
|
// Forziamo il controllo di aggiornamento della registrazione Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
@@ -169,7 +485,7 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disattiva service worker attivi per forzare il refresh completo
|
// Deregistra i vecchi Service Worker per applicare la nuova versione pulita
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
for (const registration of registrations) {
|
for (const registration of registrations) {
|
||||||
@@ -177,7 +493,7 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancella le cache del browser
|
// Pulisci le cache del browser
|
||||||
if ('caches' in window) {
|
if ('caches' in window) {
|
||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -185,104 +501,34 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
overlay.finish();
|
// Ricarica la pagina in modo trasparente
|
||||||
|
|
||||||
// Ricarica con parametro cache-busting per forzare l'allineamento remoto
|
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('update_cb', Date.now().toString());
|
url.searchParams.set('update_cb', Date.now().toString());
|
||||||
window.location.replace(url.toString());
|
window.location.replace(url.toString());
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
|
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[PWA-Update] version.json check fallito:', e);
|
console.warn('[PWA-Update] version.json check fallito:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Prova SwUpdate nel caso in cui il controllo version.json sia fallito o sia stato servito dalla cache
|
|
||||||
if (this.swUpdate.isEnabled) {
|
|
||||||
try {
|
|
||||||
console.log('[PWA-Update] Verifica aggiornamenti via SwUpdate all\'avvio...');
|
|
||||||
|
|
||||||
let activated = false;
|
|
||||||
let overlay: any = null;
|
|
||||||
|
|
||||||
const activateAndReload = async () => {
|
|
||||||
if (activated) return;
|
|
||||||
activated = true;
|
|
||||||
try {
|
|
||||||
await this.swUpdate.activateUpdate();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[PWA-Update] activateUpdate fallito all\'avvio:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deregistra i vecchi SW e cancella le cache per un ricaricamento pulito
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
for (const reg of registrations) {
|
|
||||||
await reg.unregister();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ('caches' in window) {
|
|
||||||
const keys = await caches.keys();
|
|
||||||
for (const key of keys) {
|
|
||||||
await caches.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlay) overlay.finish();
|
|
||||||
setTimeout(() => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set('update_cb', Date.now().toString());
|
|
||||||
window.location.replace(url.toString());
|
|
||||||
}, 600);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sottoscrivi PRIMA di verificare l'aggiornamento per evitare race condition
|
|
||||||
const sub = this.swUpdate.versionUpdates
|
|
||||||
.pipe(
|
|
||||||
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
|
|
||||||
first()
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio');
|
|
||||||
activateAndReload();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Concedi fino a 8 secondi al controllo SW — le connessioni mobili possono essere lente
|
|
||||||
const hasUpdate = await Promise.race([
|
|
||||||
this.swUpdate.checkForUpdate(),
|
|
||||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (hasUpdate) {
|
|
||||||
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...');
|
|
||||||
overlay = showFullscreenUpdateOverlay();
|
|
||||||
|
|
||||||
// Timeout di sicurezza di 25 secondi: se VERSION_READY non arriva, attiva comunque
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[PWA-Update] Safety timeout raggiunto all\'avvio, procedo...');
|
|
||||||
sub.unsubscribe();
|
|
||||||
activateAndReload();
|
|
||||||
}, 25000);
|
|
||||||
|
|
||||||
return true; // Attendi il ricaricamento
|
|
||||||
} else {
|
|
||||||
sub.unsubscribe();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[PWA-Update] Controllo SwUpdate fallito all\'avvio:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAndRedirectToPwa() {
|
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;
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
|
||||||
if (isStandalone) {
|
if (isStandalone) {
|
||||||
localStorage.setItem('pwa-installed', 'true');
|
localStorage.setItem('pwa-installed', 'true');
|
||||||
|
this.checkLoaderDismissal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,44 +536,52 @@ export class AppComponent implements OnInit {
|
|||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
this.protocolLink = `web+canti://open${path}${search}`;
|
this.protocolLink = `web+canti://open${path}${search}`;
|
||||||
|
|
||||||
// Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo
|
let isInstalled = false;
|
||||||
let isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
if ('getInstalledRelatedApps' in navigator) {
|
||||||
if (!isInstalled && 'getInstalledRelatedApps' in navigator) {
|
|
||||||
try {
|
try {
|
||||||
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
||||||
isInstalled = relatedApps.length > 0;
|
isInstalled = relatedApps.length > 0;
|
||||||
if (isInstalled) {
|
|
||||||
localStorage.setItem('pwa-installed', 'true');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to check installed apps:', 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 (isInstalled) {
|
||||||
const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true';
|
if (sessionStorage.getItem('skip-pwa-redirect') === 'true') {
|
||||||
if (!skipRedirect) {
|
this.showRedirectOverlay.set(false);
|
||||||
this.showRedirectOverlay.set(true);
|
this.checkLoaderDismissal();
|
||||||
this.redirectFailed.set(false);
|
return;
|
||||||
if ((window as any).PwaLoader) {
|
|
||||||
(window as any).PwaLoader.update({ isRedirect: true });
|
|
||||||
}
|
|
||||||
// Tentiamo il reindirizzamento automatico
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = this.protocolLink;
|
|
||||||
|
|
||||||
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.showRedirectOverlay()) {
|
|
||||||
this.redirectFailed.set(true);
|
|
||||||
localStorage.setItem('pwa-installed', 'false');
|
|
||||||
if ((window as any).PwaLoader) {
|
|
||||||
(window as any).PwaLoader.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}, 800);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
} else {
|
||||||
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
|
// 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 skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
|
||||||
@@ -335,6 +589,8 @@ export class AppComponent implements OnInit {
|
|||||||
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
||||||
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
|
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
|
||||||
this.showInstallOverlay.set(true);
|
this.showInstallOverlay.set(true);
|
||||||
|
} else {
|
||||||
|
this.checkLoaderDismissal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,22 +616,94 @@ export class AppComponent implements OnInit {
|
|||||||
stayInBrowser() {
|
stayInBrowser() {
|
||||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||||
this.showRedirectOverlay.set(false);
|
this.showRedirectOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
|
||||||
|
stayInBrowserForceUninstallCheck() {
|
||||||
|
localStorage.setItem('pwa-installed', 'false');
|
||||||
|
this.isPwaInstalled.set(false);
|
||||||
|
this.stayInBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeInstallOverlay() {
|
closeInstallOverlay() {
|
||||||
sessionStorage.setItem('skip-pwa-install', 'true');
|
sessionStorage.setItem('skip-pwa-install', 'true');
|
||||||
this.showInstallOverlay.set(false);
|
this.showInstallOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerInstall() {
|
async triggerInstall() {
|
||||||
await this.settingsService.installPwa();
|
this.isInstalling.set(true);
|
||||||
this.closeInstallOverlay();
|
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() {
|
async triggerInstallFromRedirect() {
|
||||||
await this.settingsService.installPwa();
|
this.isInstalling.set(true);
|
||||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
|
||||||
this.showRedirectOverlay.set(false);
|
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) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,3 +753,4 @@ export function showFullscreenUpdateOverlay() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,12 @@
|
|||||||
<zxing-scanner
|
<zxing-scanner
|
||||||
[formats]="allowedFormats"
|
[formats]="allowedFormats"
|
||||||
[device]="currentDevice"
|
[device]="currentDevice"
|
||||||
|
[autostart]="true"
|
||||||
|
[tryHarder]="false"
|
||||||
(camerasFound)="onCamerasFound($event)"
|
(camerasFound)="onCamerasFound($event)"
|
||||||
(scanSuccess)="onCodeResult($event)">
|
(scanSuccess)="onCodeResult($event)"
|
||||||
|
(scanError)="onScanError($event)"
|
||||||
|
(scanFailure)="onScanFailure($event)">
|
||||||
</zxing-scanner>
|
</zxing-scanner>
|
||||||
|
|
||||||
<div class="scan-overlay">
|
<div class="scan-overlay">
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ export class QrScannerComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
cancel() {
|
||||||
this.modalCtrl.dismiss();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+402
-175
@@ -5,20 +5,26 @@
|
|||||||
<img src="assets/icon/favicon.png" class="header-logo">
|
<img src="assets/icon/favicon.png" class="header-logo">
|
||||||
<div class="header-text-group">
|
<div class="header-text-group">
|
||||||
<span class="app-name">{{ appName }}</span>
|
<span class="app-name">{{ appName }}</span>
|
||||||
<span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
|
<span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
|
||||||
v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span>
|
v{{ version }}
|
||||||
<span *ngIf="hasUpdateAvailable()" style="display: inline-flex; align-items: center; justify-content: center; padding: 4px; margin-left: 2px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 24px; height: 24px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3);">
|
|
||||||
<ion-icon name="refresh-outline" style="font-size: 1.15rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-title>
|
</ion-title>
|
||||||
<ion-buttons slot="end">
|
<ion-buttons slot="end">
|
||||||
|
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto" *ngIf="settingsService.showEditor()">
|
||||||
<ion-button (click)="importPlaylist()" class="add-btn">
|
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
|
||||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
|
||||||
</ion-button>
|
</ion-button>
|
||||||
|
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
|
||||||
|
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
|
||||||
|
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #showQrBtn>
|
||||||
|
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||||
|
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ng-template>
|
||||||
<ion-button routerLink="/settings" class="settings-btn">
|
<ion-button routerLink="/settings" class="settings-btn">
|
||||||
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -32,113 +38,332 @@
|
|||||||
placeholder="Cerca un canto..."
|
placeholder="Cerca un canto..."
|
||||||
[value]="searchQuery()"
|
[value]="searchQuery()"
|
||||||
(ionInput)="onSearch($event)"
|
(ionInput)="onSearch($event)"
|
||||||
|
[disabled]="isAdvancedSearchOpen()"
|
||||||
class="custom-searchbar">
|
class="custom-searchbar">
|
||||||
</ion-searchbar>
|
</ion-searchbar>
|
||||||
<ion-button fill="clear" (click)="toggleVoiceSearch()" class="voice-search-btn">
|
<ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
|
||||||
<ion-icon slot="icon-only" [name]="audioEngine.isSearching() ? 'mic' : 'mic-outline'" [color]="audioEngine.isSearching() ? 'danger' : 'secondary'"></ion-icon>
|
<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>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-actions-row">
|
|
||||||
<div class="song-count-card outfit-font">
|
|
||||||
{{ filteredCanti().length }}
|
|
||||||
</div>
|
|
||||||
<div class="filter-buttons">
|
|
||||||
<ion-button
|
|
||||||
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
|
|
||||||
size="small"
|
|
||||||
(click)="toggleFilterType('playlist')"
|
|
||||||
class="filter-chip">
|
|
||||||
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
|
|
||||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null"></ion-icon>
|
|
||||||
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
|
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
|
||||||
</span>
|
|
||||||
</ion-button>
|
|
||||||
|
|
||||||
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
<!-- Advanced Search & Filters Toggle Row -->
|
||||||
<ion-button
|
<div class="advanced-search-toggle-row">
|
||||||
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
<div class="toggle-row-left">
|
||||||
size="small"
|
<div class="song-count-card outfit-font">
|
||||||
(click)="toggleComunitaFilter()"
|
{{ filteredCanti().length }}
|
||||||
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
|
</div>
|
||||||
class="filter-chip"
|
<button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
|
||||||
style="margin: 0;">
|
<ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
|
||||||
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
|
<span>Ricerche</span>
|
||||||
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
<span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
|
||||||
</ion-button>
|
<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>
|
||||||
|
|
||||||
<ion-button
|
<div class="adv-input-group">
|
||||||
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
|
<label class="adv-label">Ricerca per autore</label>
|
||||||
size="small"
|
<div class="adv-input-wrapper">
|
||||||
(click)="toggleFilterType('lista_completa')"
|
<ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
|
||||||
class="filter-chip">
|
<input
|
||||||
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
|
type="text"
|
||||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon>
|
placeholder="Nome autore..."
|
||||||
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)">
|
[value]="searchAuthor()"
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
(input)="onSearchAuthorInput($event)"
|
||||||
</span>
|
class="adv-input" />
|
||||||
</ion-button>
|
<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-button
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
[fill]="activeFilterType() === 'liturgico' || selectedLiturgico() !== null ? 'solid' : 'outline'"
|
</ion-button>
|
||||||
size="small"
|
</div>
|
||||||
(click)="toggleFilterType('liturgico')"
|
</div>
|
||||||
class="filter-chip">
|
|
||||||
{{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }}
|
|
||||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
|
|
||||||
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
|
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
|
||||||
</span>
|
|
||||||
</ion-button>
|
|
||||||
|
|
||||||
<ion-button
|
|
||||||
[fill]="activeFilterType() === 'tematico' || selectedTematico() !== null ? 'solid' : 'outline'"
|
|
||||||
size="small"
|
|
||||||
(click)="toggleFilterType('tematico')"
|
|
||||||
class="filter-chip">
|
|
||||||
{{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }}
|
|
||||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
|
|
||||||
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
|
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
|
||||||
</span>
|
|
||||||
</ion-button>
|
|
||||||
|
|
||||||
<ion-button
|
|
||||||
[fill]="showSuggeriti() ? 'solid' : 'outline'"
|
|
||||||
size="small"
|
|
||||||
(click)="toggleSuggeriti()"
|
|
||||||
class="filter-chip">
|
|
||||||
Suggeriti
|
|
||||||
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
|
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
|
||||||
</span>
|
|
||||||
</ion-button>
|
|
||||||
|
|
||||||
<ion-button
|
<div class="adv-input-group">
|
||||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
<label class="adv-label">Ricerca per testo</label>
|
||||||
size="small"
|
<div class="adv-input-wrapper">
|
||||||
(click)="toggleTopTen()"
|
<ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
|
||||||
class="filter-chip">
|
<input
|
||||||
Top Ten
|
type="text"
|
||||||
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
|
placeholder="Parole nel testo..."
|
||||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
[value]="searchText()"
|
||||||
|
(input)="onSearchTextInput($event)"
|
||||||
|
class="adv-input" />
|
||||||
|
<ion-icon name="close-circle" *ngIf="searchText()" (click)="searchText.set('')" class="adv-clear-icon"></ion-icon>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('text')" class="adv-mic-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adv-input-group">
|
||||||
|
<label class="adv-label">Ricerca per nr canto</label>
|
||||||
|
<div class="adv-input-wrapper">
|
||||||
|
<ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Numero canto..."
|
||||||
|
[value]="searchNumber()"
|
||||||
|
(input)="onSearchNumberInput($event)"
|
||||||
|
class="adv-input" />
|
||||||
|
<ion-icon name="close-circle" *ngIf="searchNumber()" (click)="searchNumber.set('')" class="adv-clear-icon"></ion-icon>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('number')" class="adv-mic-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Card (default closed) -->
|
||||||
|
<div class="filter-card glass outfit-font"
|
||||||
|
*ngIf="isPlaylistCardOpen()"
|
||||||
|
(touchstart)="onPanelTouchStart($event)"
|
||||||
|
(touchend)="onPanelTouchEnd($event, 'playlistCard')">
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Playlist</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<!-- Comunità filter button if enabled -->
|
||||||
|
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
||||||
|
<ion-button
|
||||||
|
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
||||||
|
size="small"
|
||||||
|
(click)="toggleComunitaFilter()"
|
||||||
|
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
|
||||||
|
class="filter-chip"
|
||||||
|
style="margin: 0;">
|
||||||
|
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
|
||||||
|
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Chips -->
|
||||||
|
<div
|
||||||
|
*ngFor="let item of playlistService.allPlaylists()"
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="playlistService.activePlaylistId() === item.id"
|
||||||
|
[class.comunita-chip]="item.isComunita"
|
||||||
|
(click)="playlistService.activePlaylistId() === item.id ? clearSpecialList($event) : selectPlaylist(item)">
|
||||||
|
<ion-icon *ngIf="item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
||||||
|
{{ item.name }}
|
||||||
|
<span *ngIf="playlistService.activePlaylistId() === item.id && isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 18px; height: 18px; vertical-align: middle;">
|
||||||
|
<ion-icon name="refresh-outline" style="font-size: 0.85rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
|
||||||
|
</span>
|
||||||
|
<span class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
|
||||||
|
Nessuna playlist creata
|
||||||
</span>
|
</span>
|
||||||
</ion-button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Card (default closed) -->
|
||||||
|
<div class="filter-card glass outfit-font"
|
||||||
|
*ngIf="isFilterCardOpen()"
|
||||||
|
(touchstart)="onPanelTouchStart($event)"
|
||||||
|
(touchend)="onPanelTouchEnd($event, 'filterCard')">
|
||||||
|
|
||||||
|
<!-- Row 2: Tipologia -->
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Tipologia</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<div
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="showValidati()"
|
||||||
|
(click)="toggleValidati()">
|
||||||
|
Validati
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showValidati()" (click)="clearListaCompleta($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="showNonValidati()"
|
||||||
|
(click)="toggleNonValidati()"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 6px;">
|
||||||
|
<span>Non Validati</span>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
|
||||||
|
name="add-outline"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
routerLink="/propose-canto"
|
||||||
|
class="non-validati-add-icon">
|
||||||
|
</ion-icon>
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showNonValidati()" (click)="clearListaCompleta($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Liturgia -->
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Liturgia</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<div
|
||||||
|
*ngFor="let item of cantiService.indiceLiturgico()"
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="selectedLiturgico() === item.id"
|
||||||
|
(click)="toggleIndex(item.id, 'liturgico')">
|
||||||
|
{{ item.tag_name }}
|
||||||
|
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() === item.id" (click)="clearLiturgico($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: Periodo -->
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Periodo</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<div
|
||||||
|
*ngFor="let item of cantiService.indiceTematico()"
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="selectedTematico() === item.id"
|
||||||
|
(click)="toggleIndex(item.id, 'tematico')">
|
||||||
|
{{ item.tag_name }}
|
||||||
|
<span class="close-icon-wrapper" *ngIf="selectedTematico() === item.id" (click)="clearTematico($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 5 (Footer): Suggeriti, Top Ten & Reset filtri -->
|
||||||
|
<div class="filter-card-footer">
|
||||||
|
<div class="footer-actions">
|
||||||
|
<ion-button
|
||||||
|
[fill]="showSuggeriti() ? 'solid' : 'outline'"
|
||||||
|
size="small"
|
||||||
|
(click)="toggleSuggeriti()"
|
||||||
|
class="filter-chip">
|
||||||
|
Suggeriti
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
|
||||||
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button
|
||||||
|
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||||
|
size="small"
|
||||||
|
(click)="toggleTopTen()"
|
||||||
|
class="filter-chip">
|
||||||
|
Top Ten
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
|
||||||
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button
|
||||||
|
[fill]="showOnlyMine() ? 'solid' : 'outline'"
|
||||||
|
size="small"
|
||||||
|
(click)="toggleOnlyMine()"
|
||||||
|
class="filter-chip">
|
||||||
|
Miei
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
|
||||||
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
|
||||||
|
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
|
||||||
|
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
|
||||||
|
<div class="playlist-actions-bar">
|
||||||
|
<!-- Duration Badge on the left -->
|
||||||
|
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
|
||||||
|
<ion-icon name="time-outline"></ion-icon>
|
||||||
|
<span>{{ totalPlaylistDuration() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Name in middle -->
|
||||||
|
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
|
||||||
|
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Playlist Actions -->
|
||||||
|
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
|
||||||
|
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
|
||||||
|
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
|
||||||
|
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
|
||||||
|
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
||||||
|
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
|
||||||
|
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Category Scroll (Dropdown style) -->
|
|
||||||
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="shouldShowSubSectionToolbar()">
|
|
||||||
<div class="momento-scroll" *ngIf="activeFilterType() !== 'playlist' || playlistService.allPlaylists().length > 0 || playlistService.selectionMode()">
|
|
||||||
<!-- Selection Mode Actions -->
|
<!-- Selection Mode Actions -->
|
||||||
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
|
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
|
||||||
<span class="selection-count">{{ reorderList().length }}</span>
|
<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-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn" *ngIf="reorderList().length > 0">
|
||||||
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -149,62 +374,6 @@
|
|||||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Playlist Actions -->
|
|
||||||
<div class="selection-pill glass" *ngIf="activeFilterType() === 'playlist' && playlistService.activePlaylistId() && !playlistService.selectionMode()">
|
|
||||||
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
|
|
||||||
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
|
||||||
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
|
|
||||||
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter chips -->
|
|
||||||
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
|
|
||||||
<div
|
|
||||||
class="momento-chip glass"
|
|
||||||
[class.active]="showValidati()"
|
|
||||||
(click)="toggleValidati()">
|
|
||||||
Validati
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="momento-chip glass"
|
|
||||||
[class.active]="showNonValidati()"
|
|
||||||
(click)="toggleNonValidati()"
|
|
||||||
style="display: inline-flex; align-items: center; gap: 6px;">
|
|
||||||
<span>Non Validati</span>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
|
|
||||||
name="add-outline"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
routerLink="/propose-canto"
|
|
||||||
class="non-validati-add-icon">
|
|
||||||
</ion-icon>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="activeFilterType() !== 'lista_completa'">
|
|
||||||
<div
|
|
||||||
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
|
|
||||||
class="momento-chip glass"
|
|
||||||
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
|
|
||||||
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
|
|
||||||
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
|
|
||||||
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
|
||||||
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Instructions when playlist is empty -->
|
|
||||||
<div class="empty-playlist-container" *ngIf="activeFilterType() === 'playlist' && playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()">
|
|
||||||
<p class="empty-playlist-instruction outfit-font">
|
|
||||||
Per creare una playlist, seleziona il nr del canto che vuoi inserire nella playlist, riordinali e salvala con nome
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
@@ -221,6 +390,7 @@
|
|||||||
<div class="loading-wrapper">
|
<div class="loading-wrapper">
|
||||||
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
||||||
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
|
<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>
|
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +471,23 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||||
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
||||||
|
</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;">
|
<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>
|
<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) }}
|
agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
|
||||||
@@ -352,8 +539,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Button (only for local songs) -->
|
<!-- Delete Button (only for "mio" songs when "Miei" filter is active) -->
|
||||||
<div *ngIf="canto.id.startsWith('my_')" class="delete-section" (click)="$event.stopPropagation()">
|
<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-button fill="clear" color="danger" (click)="deleteMyCanto(canto.id, $event)">
|
||||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -379,46 +566,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
|
||||||
</ion-infinite-scroll-content>
|
</ion-infinite-scroll-content>
|
||||||
</ion-infinite-scroll>
|
</ion-infinite-scroll>
|
||||||
|
|
||||||
</ion-content>
|
</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">
|
<ion-toolbar class="global-player-toolbar glass">
|
||||||
<div class="player-content">
|
<div class="player-content">
|
||||||
|
|
||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
<ion-button fill="clear" color="secondary" (click)="playPrevPreview($event)" class="skip-btn">
|
<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>
|
||||||
|
|
||||||
<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-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn">
|
||||||
</ion-button>
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
<ion-range
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||||
[min]="0"
|
|
||||||
[max]="youtubePlayerService.videoDuration()"
|
<ion-range
|
||||||
[value]="youtubePlayerService.videoProgress()"
|
[min]="0"
|
||||||
(ionChange)="onSeek($event)"
|
[max]="youtubePlayerService.videoDuration()"
|
||||||
(ionKnobMoveStart)="onSeekStart()"
|
[value]="youtubePlayerService.videoProgress()"
|
||||||
(ionKnobMoveEnd)="onSeekEnd()"
|
(ionChange)="onSeek($event)"
|
||||||
color="secondary"
|
(ionKnobMoveStart)="onSeekStart()"
|
||||||
class="global-range">
|
(ionKnobMoveEnd)="onSeekEnd()"
|
||||||
</ion-range>
|
color="secondary"
|
||||||
|
class="global-range"
|
||||||
|
style="margin: 0; padding: 0 4px;">
|
||||||
|
</ion-range>
|
||||||
|
|
||||||
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noAudioMsg>
|
||||||
|
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
|
||||||
|
Audio non disponibile
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
|
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
|
||||||
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
|
||||||
</ion-button>
|
|
||||||
|
|
||||||
<ion-button fill="clear" color="medium" (click)="stopVideo($event)" class="close-btn">
|
|
||||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-footer>
|
</ion-footer>
|
||||||
|
|
||||||
|
<div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)">
|
||||||
|
<div class="black-screen-content" (click)="$event.stopPropagation()">
|
||||||
|
<div (click)="isBlackOverlayDismissed.set(true)" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
|
||||||
|
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-canto-info" *ngIf="getPlayingCanto()">
|
||||||
|
<h2 class="car-mode-canto-title outfit-font">{{ getPlayingCanto()?.titolo }}</h2>
|
||||||
|
<p class="car-mode-canto-author outfit-font">{{ getPlayingCanto()?.autore }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-progress outfit-font" *ngIf="youtubePlayerService.currentCantoId()">
|
||||||
|
<span class="car-mode-time">{{ formatSeconds(youtubePlayerService.videoProgress()) }} / {{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-controls">
|
||||||
|
<ion-button fill="clear" (click)="playPrevPreview($event)" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="youtubePlayerService.isPlaying() ? pauseVideo($event) : playVideo($event, youtubePlayerService.currentCantoId()!)" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="playNextPreview($event)" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="car-mode-subtext outfit-font" (click)="isBlackOverlayDismissed.set(true)">Tocca lo sfondo o l'icona per sbloccare</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+618
-84
@@ -48,95 +48,447 @@
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
.voice-search-btn {
|
.adv-search-btn, .voice-search-btn {
|
||||||
--padding-start: 8px;
|
--padding-start: 6px;
|
||||||
--padding-end: 8px;
|
--padding-end: 6px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
font-size: 1.5rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adv-search-btn.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-actions-row {
|
.advanced-search-toggle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
justify-content: space-between;
|
||||||
overflow-x: auto;
|
padding: 0 2px;
|
||||||
gap: 12px;
|
gap: 4px;
|
||||||
padding: 8px 4px 8px 0;
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
// Hide scrollbar but keep functionality
|
.toggle-row-left {
|
||||||
&::-webkit-scrollbar {
|
display: flex;
|
||||||
display: none;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
.song-count-card {
|
.song-count-card {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||||
padding: 0 12px;
|
padding: 0 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
height: 32px;
|
height: 30px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-buttons {
|
.advanced-search-link, .clear-advanced-link {
|
||||||
display: flex;
|
background: transparent;
|
||||||
gap: 8px;
|
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;
|
flex-shrink: 0;
|
||||||
|
|
||||||
ion-button.filter-chip {
|
&:hover, &:active {
|
||||||
--border-radius: 20px;
|
opacity: 1;
|
||||||
--border-width: 1px;
|
background: rgba(var(--ion-color-secondary-rgb), 0.12);
|
||||||
font-family: 'Outfit', sans-serif;
|
}
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
min-height: 32px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
|
|
||||||
.close-icon-wrapper {
|
ion-icon {
|
||||||
display: inline-flex;
|
font-size: 1rem;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: center;
|
|
||||||
margin-left: 6px;
|
.inline-clear-btn {
|
||||||
padding: 4px;
|
display: inline-flex;
|
||||||
margin-right: -8px;
|
align-items: center;
|
||||||
cursor: pointer;
|
justify-content: center;
|
||||||
z-index: 100;
|
margin-left: 2px;
|
||||||
|
padding: 2px;
|
||||||
ion-icon {
|
border-radius: 50%;
|
||||||
font-size: 1.2rem;
|
transition: transform 0.15s ease;
|
||||||
margin: 0;
|
|
||||||
pointer-events: none;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
&: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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
.adv-clear-icon {
|
||||||
opacity: 0.5;
|
font-size: 1rem;
|
||||||
transform: scale(0.9);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
ion-button.filter-chip {
|
||||||
|
--border-radius: 20px;
|
||||||
|
--border-width: 1px;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
|
||||||
|
.close-icon-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
margin-right: -8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 {
|
.transparent-list {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
padding-bottom: 120px; // Spazio per il player fisso
|
padding-bottom: 120px; // Spazio per il player fisso
|
||||||
@@ -148,10 +500,9 @@ ion-item.glass {
|
|||||||
--padding-start: 16px;
|
--padding-start: 16px;
|
||||||
--inner-padding-end: 16px;
|
--inner-padding-end: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
transition: transform 0.2s ease, background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
|
||||||
--background: rgba(255, 255, 255, 0.1);
|
--background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,28 +512,87 @@ ion-title {
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
padding-inline: 0;
|
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 {
|
.header-logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room
|
padding: 10px 0 10px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-logo {
|
.header-logo {
|
||||||
width: 42px;
|
width: 38px;
|
||||||
height: 42px;
|
height: 38px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||||
border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-text-group {
|
.header-text-group {
|
||||||
@@ -193,24 +603,26 @@ ion-title {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
font-size: 1.4rem;
|
font-size: clamp(1.1rem, 3.8vw, 1.25rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-badge {
|
.version-badge {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
margin-right: 16px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.special-list-banner {
|
.special-list-banner {
|
||||||
@@ -384,6 +796,12 @@ ion-title {
|
|||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
background: rgba(255, 255, 255, 0.2) !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 Layout
|
||||||
@@ -426,7 +844,8 @@ ion-title {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.selection-column {
|
.selection-column {
|
||||||
padding: 10px 14px 10px 0;
|
padding: 12px 18px 12px 12px;
|
||||||
|
margin-left: -12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -443,6 +862,11 @@ ion-title {
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -671,6 +1095,30 @@ ion-title {
|
|||||||
font-size: 1.2rem;
|
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 {
|
.small-action-btn {
|
||||||
@@ -725,7 +1173,6 @@ ion-title {
|
|||||||
&.glass {
|
&.glass {
|
||||||
background: rgba(26, 26, 46, 0.95) !important;
|
background: rgba(26, 26, 46, 0.95) !important;
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
@@ -741,25 +1188,14 @@ ion-title {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
.play-btn {
|
.play-btn, .skip-btn, .close-btn {
|
||||||
--padding-start: 0;
|
--padding-start: 0;
|
||||||
--padding-end: 0;
|
--padding-end: 0;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
width: 44px;
|
width: 40px;
|
||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
font-size: 2.2rem;
|
font-size: 1.6rem;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-btn, .close-btn {
|
|
||||||
--padding-start: 0;
|
|
||||||
--padding-end: 0;
|
|
||||||
height: 36px;
|
|
||||||
width: 36px;
|
|
||||||
|
|
||||||
ion-icon {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,4 +1773,102 @@ ion-title {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+641
-50
@@ -1,4 +1,4 @@
|
|||||||
import { Component, computed, signal, inject, effect, OnDestroy } from '@angular/core';
|
import { Component, computed, signal, inject, effect, OnDestroy, HostListener } from '@angular/core';
|
||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { CantiService } from '../services/canti.service';
|
import { CantiService } from '../services/canti.service';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
@@ -19,6 +19,7 @@ import { environment } from '../../environments/environment';
|
|||||||
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||||
import { filter, first } from 'rxjs/operators';
|
import { filter, first } from 'rxjs/operators';
|
||||||
import { showFullscreenUpdateOverlay } from '../app.component';
|
import { showFullscreenUpdateOverlay } from '../app.component';
|
||||||
|
import { LyricsParserService } from '../services/lyrics-parser.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
@@ -27,7 +28,41 @@ import { showFullscreenUpdateOverlay } from '../app.component';
|
|||||||
standalone: false,
|
standalone: false,
|
||||||
})
|
})
|
||||||
export class HomePage implements OnDestroy {
|
export class HomePage implements OnDestroy {
|
||||||
|
@HostListener('window:keydown', ['$event'])
|
||||||
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
|
// Only handle keys if a song is currently loaded in the player
|
||||||
|
if (!this.youtubePlayerService.currentCantoId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'MediaTrackPrevious') {
|
||||||
|
this.playPrevPreview();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'ArrowDown' || event.key === 'MediaTrackNext') {
|
||||||
|
this.playNextPreview();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'MediaPlayPause') {
|
||||||
|
this.togglePlayPause();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public searchQuery = signal<string>('');
|
public searchQuery = signal<string>('');
|
||||||
|
public isAdvancedSearchOpen = signal<boolean>(false);
|
||||||
|
public isFilterCardOpen = signal<boolean>(false);
|
||||||
|
public isPlaylistCardOpen = signal<boolean>(false);
|
||||||
|
public searchTitle = signal<string>('');
|
||||||
|
public searchAuthor = signal<string>('');
|
||||||
|
public searchText = signal<string>('');
|
||||||
|
public searchNumber = signal<string>('');
|
||||||
|
public activeVoiceField = signal<'global' | 'title' | 'author' | 'text' | 'number'>('global');
|
||||||
|
|
||||||
|
public hasAdvancedSearchParams = computed(() => {
|
||||||
|
return !!(this.searchTitle().trim() || this.searchAuthor().trim() || this.searchText().trim() || this.searchNumber().trim());
|
||||||
|
});
|
||||||
public selectedLiturgico = signal<number | null>(null);
|
public selectedLiturgico = signal<number | null>(null);
|
||||||
public selectedTematico = signal<number | null>(null);
|
public selectedTematico = signal<number | null>(null);
|
||||||
public showOnlyMine = signal<boolean>(false);
|
public showOnlyMine = signal<boolean>(false);
|
||||||
@@ -35,6 +70,25 @@ export class HomePage implements OnDestroy {
|
|||||||
public showSuggeriti = signal<boolean>(false);
|
public showSuggeriti = signal<boolean>(false);
|
||||||
public showValidati = signal<boolean>(false);
|
public showValidati = signal<boolean>(false);
|
||||||
public showNonValidati = signal<boolean>(false);
|
public showNonValidati = signal<boolean>(false);
|
||||||
|
|
||||||
|
public hasPlaylistCardParams = computed(() => {
|
||||||
|
return !!(
|
||||||
|
this.playlistService.activePlaylistId() !== null ||
|
||||||
|
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
public hasFilterCardParams = computed(() => {
|
||||||
|
return !!(
|
||||||
|
this.selectedLiturgico() !== null ||
|
||||||
|
this.selectedTematico() !== null ||
|
||||||
|
this.showOnlyMine() ||
|
||||||
|
this.showTopTen() ||
|
||||||
|
this.showSuggeriti() ||
|
||||||
|
this.showValidati() ||
|
||||||
|
this.showNonValidati()
|
||||||
|
);
|
||||||
|
});
|
||||||
public isMassCardExpanded = signal<boolean>(false);
|
public isMassCardExpanded = signal<boolean>(false);
|
||||||
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
|
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
|
||||||
public loadedThumbs = new Set<string>();
|
public loadedThumbs = new Set<string>();
|
||||||
@@ -45,6 +99,13 @@ export class HomePage implements OnDestroy {
|
|||||||
public limit = signal<number>(30);
|
public limit = signal<number>(30);
|
||||||
public hasUpdateAvailable = signal<boolean>(false);
|
public hasUpdateAvailable = signal<boolean>(false);
|
||||||
|
|
||||||
|
public isBlackOverlayDismissed = signal<boolean>(false);
|
||||||
|
public isBlackOverlayActive = computed(() =>
|
||||||
|
this.settingsService.carModeBlackScreen() &&
|
||||||
|
this.youtubePlayerService.currentCantoId() !== null &&
|
||||||
|
!this.isBlackOverlayDismissed()
|
||||||
|
);
|
||||||
|
|
||||||
public fontSize = signal<number>(1.0);
|
public fontSize = signal<number>(1.0);
|
||||||
public youtubePlayerService = inject(YoutubePlayerService);
|
public youtubePlayerService = inject(YoutubePlayerService);
|
||||||
public comunitaService = inject(ComunitaService);
|
public comunitaService = inject(ComunitaService);
|
||||||
@@ -64,6 +125,7 @@ export class HomePage implements OnDestroy {
|
|||||||
public playlistService = inject(PlaylistService);
|
public playlistService = inject(PlaylistService);
|
||||||
public settingsService = inject(SettingsService);
|
public settingsService = inject(SettingsService);
|
||||||
public cantiLettureService = inject(CantiLettureService);
|
public cantiLettureService = inject(CantiLettureService);
|
||||||
|
public lyricsParser = inject(LyricsParserService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private modalCtrl = inject(ModalController);
|
private modalCtrl = inject(ModalController);
|
||||||
@@ -174,6 +236,24 @@ export class HomePage implements OnDestroy {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[PWA-Update] activateUpdate failed:', e);
|
console.warn('[PWA-Update] activateUpdate failed:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
for (const registration of registrations) {
|
||||||
|
await registration.unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
for (const key of keys) {
|
||||||
|
await caches.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PWA-Update] Cleanup failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
overlay.finish();
|
overlay.finish();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
|
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
|
||||||
@@ -208,17 +288,12 @@ export class HomePage implements OnDestroy {
|
|||||||
|
|
||||||
private async checkUpdateStatus() {
|
private async checkUpdateStatus() {
|
||||||
try {
|
try {
|
||||||
|
let swFoundUpdate = false;
|
||||||
if (this.swUpdate.isEnabled) {
|
if (this.swUpdate.isEnabled) {
|
||||||
const swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
|
swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
|
||||||
if (swFoundUpdate) {
|
|
||||||
this.hasUpdateAvailable.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const mismatch = await this.checkVersionJson();
|
const mismatch = await this.checkVersionJson();
|
||||||
if (mismatch) {
|
this.hasUpdateAvailable.set(swFoundUpdate || mismatch);
|
||||||
this.hasUpdateAvailable.set(true);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to check for updates on startup:', err);
|
console.warn('Failed to check for updates on startup:', err);
|
||||||
}
|
}
|
||||||
@@ -348,7 +423,7 @@ export class HomePage implements OnDestroy {
|
|||||||
|
|
||||||
// Filter by Validati (non personali e non contrassegnati come non validati)
|
// Filter by Validati (non personali e non contrassegnati come non validati)
|
||||||
if (this.showValidati()) {
|
if (this.showValidati()) {
|
||||||
list = list.filter(c => !c.nonValidato && !c.id.startsWith('my_'));
|
list = list.filter(c => !c.nonValidato && (onlyMine || !c.id.startsWith('my_')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by Non-Validati (personali o esplicitamente non validati)
|
// Filter by Non-Validati (personali o esplicitamente non validati)
|
||||||
@@ -384,36 +459,85 @@ export class HomePage implements OnDestroy {
|
|||||||
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const tokens = query.split(/\s+/).filter(t => t.length > 0);
|
||||||
|
|
||||||
list = list.filter(c => {
|
list = list.filter(c => {
|
||||||
const titleMatch = this.normalize(c.titolo).includes(query);
|
const normTitle = this.normalize(c.titolo);
|
||||||
const authorMatch = this.normalize(c.autore).includes(query);
|
const normAuthor = this.normalize(c.autore);
|
||||||
const commNum = this.getCommunitySongNumber(c);
|
const commNum = this.getCommunitySongNumber(c);
|
||||||
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
|
const songNum = commNum || c.id_canti.toString();
|
||||||
|
|
||||||
const lyricsPlain = (c.testo || '')
|
const lyricsPlain = (c.testo || '')
|
||||||
.replace(/{.*?}/g, '')
|
.replace(/{.*?}/g, '')
|
||||||
.replace(/\n/g, ' ');
|
.replace(/\n/g, ' ');
|
||||||
const lyricsMatch = this.normalize(lyricsPlain).includes(query);
|
const normLyrics = this.normalize(lyricsPlain);
|
||||||
|
|
||||||
return titleMatch || authorMatch || lyricsMatch || numberMatch;
|
// Full string matches in title, author, lyrics, or song number
|
||||||
|
const titleMatch = normTitle.includes(query);
|
||||||
|
const authorMatch = normAuthor.includes(query);
|
||||||
|
const lyricsMatch = normLyrics.includes(query);
|
||||||
|
const numberMatch = songNum.includes(query);
|
||||||
|
|
||||||
|
if (titleMatch || authorMatch || lyricsMatch || numberMatch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-word token match across fields (e.g. title word + author word)
|
||||||
|
if (tokens.length > 1) {
|
||||||
|
return tokens.every(token =>
|
||||||
|
normTitle.includes(token) ||
|
||||||
|
normAuthor.includes(token) ||
|
||||||
|
normLyrics.includes(token) ||
|
||||||
|
songNum.includes(token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced search filters (titolo, autore, testo, nr canto)
|
||||||
|
const advTitle = this.normalize(this.searchTitle()).trim();
|
||||||
|
const advAuthor = this.normalize(this.searchAuthor()).trim();
|
||||||
|
const advText = this.normalize(this.searchText()).trim();
|
||||||
|
const advNumber = this.normalize(this.searchNumber()).trim();
|
||||||
|
|
||||||
|
if (advTitle) {
|
||||||
|
list = list.filter(c => this.normalize(c.titolo).includes(advTitle));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advAuthor) {
|
||||||
|
list = list.filter(c => this.normalize(c.autore || '').includes(advAuthor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advText) {
|
||||||
|
list = list.filter(c => {
|
||||||
|
const lyricsPlain = (c.testo || '')
|
||||||
|
.replace(/{.*?}/g, '')
|
||||||
|
.replace(/\n/g, ' ');
|
||||||
|
return this.normalize(lyricsPlain).includes(advText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advNumber) {
|
||||||
|
list = list.filter(c => {
|
||||||
|
const commNum = this.getCommunitySongNumber(c);
|
||||||
|
const songNum = (commNum ? commNum.toString() : c.id_canti.toString()).toLowerCase().trim();
|
||||||
|
return songNum === advNumber.toLowerCase().trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// G. Sorting/Ordering
|
// G. Sorting/Ordering
|
||||||
if (topTen) {
|
if (topTen) {
|
||||||
const eseguiti = this.cantiService.cantiEseguiti();
|
list = [...list].sort((a, b) => {
|
||||||
const eseguitiMap = new Map<number, number>();
|
const numA = this.getEsecuzioniCount(a.id) || 0;
|
||||||
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
const numB = this.getEsecuzioniCount(b.id) || 0;
|
||||||
|
|
||||||
list = list.sort((a, b) => {
|
|
||||||
const numA = eseguitiMap.get(a.id_canti) || 0;
|
|
||||||
const numB = eseguitiMap.get(b.id_canti) || 0;
|
|
||||||
return numB - numA;
|
return numB - numA;
|
||||||
});
|
});
|
||||||
} else if (suggeriti) {
|
} else if (suggeriti) {
|
||||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||||
list = list.sort((a, b) => {
|
list = [...list].sort((a, b) => {
|
||||||
const pesoA = suggMap.get(a.id_canti) || 0;
|
const pesoA = suggMap.get(a.id_canti) || 0;
|
||||||
const pesoB = suggMap.get(b.id_canti) || 0;
|
const pesoB = suggMap.get(b.id_canti) || 0;
|
||||||
return pesoB - pesoA;
|
return pesoB - pesoA;
|
||||||
@@ -427,9 +551,69 @@ export class HomePage implements OnDestroy {
|
|||||||
if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
|
if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
|
||||||
return this.filteredCanti();
|
return this.filteredCanti();
|
||||||
}
|
}
|
||||||
|
if (this.comunitaService.isFilterActive() && this.comunitaService.comunitaCode()) {
|
||||||
|
return this.filteredCanti();
|
||||||
|
}
|
||||||
return this.filteredCanti().slice(0, this.limit());
|
return this.filteredCanti().slice(0, this.limit());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public hasAnyFilter = computed(() => {
|
||||||
|
return (
|
||||||
|
this.playlistService.activeListName() !== null ||
|
||||||
|
this.selectedLiturgico() !== null ||
|
||||||
|
this.selectedTematico() !== null ||
|
||||||
|
this.showOnlyMine() ||
|
||||||
|
this.showTopTen() ||
|
||||||
|
this.showSuggeriti() ||
|
||||||
|
this.showValidati() ||
|
||||||
|
this.showNonValidati() ||
|
||||||
|
!!this.searchQuery().trim() ||
|
||||||
|
this.hasAdvancedSearchParams() ||
|
||||||
|
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
public totalPlaylistDuration = computed(() => {
|
||||||
|
if (this.playlistService.activePlaylistId() === null) return '';
|
||||||
|
const songs = this.filteredCanti();
|
||||||
|
if (songs.length === 0) return '';
|
||||||
|
|
||||||
|
let totalSeconds = 0;
|
||||||
|
for (const song of songs) {
|
||||||
|
if (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() {
|
constructor() {
|
||||||
// Check if there is an update available
|
// Check if there is an update available
|
||||||
this.checkUpdateStatus();
|
this.checkUpdateStatus();
|
||||||
@@ -444,8 +628,15 @@ export class HomePage implements OnDestroy {
|
|||||||
// Polling setup: check for updates every 30 seconds
|
// Polling setup: check for updates every 30 seconds
|
||||||
this.updatePollInterval = setInterval(() => {
|
this.updatePollInterval = setInterval(() => {
|
||||||
this.checkUpdateStatus();
|
this.checkUpdateStatus();
|
||||||
|
if (this.isRemotePlaylist()) {
|
||||||
|
this.playlistService.checkForRemotePlaylistUpdates();
|
||||||
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
// Sync homepage filtered canti IDs to playlistService.filteredListIds
|
||||||
|
effect(() => {
|
||||||
|
const ids = this.filteredCanti().map(c => c.id);
|
||||||
|
this.playlistService.filteredListIds.set(ids);
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
|
|
||||||
// Track initial community filter state to avoid clearing during the initial run of the effect
|
// Track initial community filter state to avoid clearing during the initial run of the effect
|
||||||
@@ -477,14 +668,33 @@ export class HomePage implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
// Sync speech recognition results to search query
|
// Sync speech recognition results to search query or advanced field
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const transcript = this.audioEngine.searchTranscript();
|
const transcript = this.audioEngine.searchTranscript();
|
||||||
if (transcript) {
|
if (transcript) {
|
||||||
this.searchQuery.set(transcript);
|
const target = this.activeVoiceField();
|
||||||
|
switch (target) {
|
||||||
|
case 'title':
|
||||||
|
this.searchTitle.set(transcript);
|
||||||
|
break;
|
||||||
|
case 'author':
|
||||||
|
this.searchAuthor.set(transcript);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
this.searchText.set(transcript);
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
this.searchNumber.set(transcript);
|
||||||
|
break;
|
||||||
|
case 'global':
|
||||||
|
default:
|
||||||
|
this.searchQuery.set(transcript);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let queryParamsSubscribed = false;
|
let queryParamsSubscribed = false;
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) {
|
if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) {
|
||||||
@@ -526,9 +736,6 @@ export class HomePage implements OnDestroy {
|
|||||||
hasAutoActivatedPlaylist = true;
|
hasAutoActivatedPlaylist = true;
|
||||||
this.selectPlaylist(lastPl);
|
this.selectPlaylist(lastPl);
|
||||||
this.activeFilterType.set('playlist');
|
this.activeFilterType.set('playlist');
|
||||||
if (lastPl.ids && lastPl.ids.length > 0) {
|
|
||||||
this.goToCanto(lastPl.ids[0]);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
hasAutoActivatedPlaylist = true;
|
hasAutoActivatedPlaylist = true;
|
||||||
}
|
}
|
||||||
@@ -589,6 +796,25 @@ export class HomePage implements OnDestroy {
|
|||||||
this.isAddingSongs.set(false);
|
this.isAddingSongs.set(false);
|
||||||
}
|
}
|
||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
|
// Fullscreen control when black screen overlay is active
|
||||||
|
effect(() => {
|
||||||
|
const isBlack = this.isBlackOverlayActive();
|
||||||
|
if (isBlack) {
|
||||||
|
this.enterFullscreen();
|
||||||
|
} else {
|
||||||
|
this.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ionViewWillEnter() {
|
||||||
|
// Gestione dati QR scansionati e passati via router state dalle Impostazioni
|
||||||
|
const navigation = this.router.getCurrentNavigation();
|
||||||
|
const state = navigation?.extras.state as { scannedQrData?: string } | undefined;
|
||||||
|
if (state?.scannedQrData) {
|
||||||
|
this.handleScannedData(state.scannedQrData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -645,14 +871,10 @@ export class HomePage implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openFirst = this.route.snapshot.queryParams['openFirst'] === '1';
|
|
||||||
if (this.playlistService.processImportJson(json)) {
|
if (this.playlistService.processImportJson(json)) {
|
||||||
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
|
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
|
||||||
this.activeFilterType.set('playlist');
|
this.activeFilterType.set('playlist');
|
||||||
await this.router.navigate([], { queryParams: { import: null, openFirst: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
await this.router.navigate([], { queryParams: { import: null, openFirst: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
if (openFirst && json.ids && json.ids.length > 0) {
|
|
||||||
this.goToCanto(json.ids[0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to import playlist', e);
|
console.error('Failed to import playlist', e);
|
||||||
@@ -745,7 +967,9 @@ export class HomePage implements OnDestroy {
|
|||||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||||
link_youtube: item.link_youtube || '',
|
link_youtube: item.link_youtube || '',
|
||||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
|
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
|
||||||
nonValidato: true
|
nonValidato: true,
|
||||||
|
durata: item.durata || '',
|
||||||
|
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.playlistService.saveRemoteShareCanto(canto);
|
await this.playlistService.saveRemoteShareCanto(canto);
|
||||||
@@ -803,7 +1027,9 @@ export class HomePage implements OnDestroy {
|
|||||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||||
autore: item.autore || '',
|
autore: item.autore || '',
|
||||||
link_youtube: item.link_youtube || '',
|
link_youtube: item.link_youtube || '',
|
||||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
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
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 2. Reconstruct playlists
|
// 2. Reconstruct playlists
|
||||||
@@ -848,6 +1074,7 @@ export class HomePage implements OnDestroy {
|
|||||||
|
|
||||||
// Select it immediately
|
// Select it immediately
|
||||||
this.selectPlaylist(selectedPl);
|
this.selectPlaylist(selectedPl);
|
||||||
|
this.playlistService.lastPlaylist.set(selectedPl);
|
||||||
this.activeFilterType.set('playlist');
|
this.activeFilterType.set('playlist');
|
||||||
|
|
||||||
await loading.dismiss();
|
await loading.dismiss();
|
||||||
@@ -860,11 +1087,7 @@ export class HomePage implements OnDestroy {
|
|||||||
await toast.present();
|
await toast.present();
|
||||||
|
|
||||||
if (selectedPl.ids && selectedPl.ids.length > 0) {
|
if (selectedPl.ids && selectedPl.ids.length > 0) {
|
||||||
hasNavigatedToCanto = true;
|
|
||||||
await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null, 'openFirst': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null, 'openFirst': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
if (openFirst) {
|
|
||||||
this.goToCanto(selectedPl.ids[0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Formato dati non valido.');
|
throw new Error('Formato dati non valido.');
|
||||||
@@ -922,7 +1145,9 @@ export class HomePage implements OnDestroy {
|
|||||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||||
autore: item.autore || '',
|
autore: item.autore || '',
|
||||||
link_youtube: item.link_youtube || '',
|
link_youtube: item.link_youtube || '',
|
||||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
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) {
|
if (customSongs.length > 0) {
|
||||||
@@ -1004,6 +1229,95 @@ export class HomePage implements OnDestroy {
|
|||||||
this.limit.set(30);
|
this.limit.set(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleAdvancedSearch() {
|
||||||
|
const willOpen = !this.isAdvancedSearchOpen();
|
||||||
|
this.isAdvancedSearchOpen.set(willOpen);
|
||||||
|
if (willOpen) {
|
||||||
|
this.isFilterCardOpen.set(false);
|
||||||
|
this.isPlaylistCardOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFilterCard() {
|
||||||
|
const willOpen = !this.isFilterCardOpen();
|
||||||
|
this.isFilterCardOpen.set(willOpen);
|
||||||
|
if (willOpen) {
|
||||||
|
this.isAdvancedSearchOpen.set(false);
|
||||||
|
this.isPlaylistCardOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlaylistCard() {
|
||||||
|
const willOpen = !this.isPlaylistCardOpen();
|
||||||
|
this.isPlaylistCardOpen.set(willOpen);
|
||||||
|
if (willOpen) {
|
||||||
|
this.isAdvancedSearchOpen.set(false);
|
||||||
|
this.isFilterCardOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAdvancedSearch(event?: Event) {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.searchTitle.set('');
|
||||||
|
this.searchAuthor.set('');
|
||||||
|
this.searchText.set('');
|
||||||
|
this.searchNumber.set('');
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilterCard(event?: Event) {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.selectedLiturgico.set(null);
|
||||||
|
this.selectedTematico.set(null);
|
||||||
|
this.showOnlyMine.set(false);
|
||||||
|
this.showTopTen.set(false);
|
||||||
|
this.showSuggeriti.set(false);
|
||||||
|
this.showValidati.set(false);
|
||||||
|
this.showNonValidati.set(false);
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPlaylistCard(event?: Event) {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.playlistService.activeListIds.set([]);
|
||||||
|
this.playlistService.activeListName.set(null);
|
||||||
|
this.playlistService.activePlaylistId.set(null);
|
||||||
|
if (this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive()) {
|
||||||
|
this.toggleComunitaFilter();
|
||||||
|
}
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchTitleInput(event: any) {
|
||||||
|
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||||
|
this.searchTitle.set(val);
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchAuthorInput(event: any) {
|
||||||
|
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||||
|
this.searchAuthor.set(val);
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchTextInput(event: any) {
|
||||||
|
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||||
|
this.searchText.set(val);
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchNumberInput(event: any) {
|
||||||
|
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||||
|
this.searchNumber.set(val);
|
||||||
|
this.limit.set(30);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteMyCanto(id: string, event: Event) {
|
async deleteMyCanto(id: string, event: Event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const alert = await this.alertCtrl.create({
|
const alert = await this.alertCtrl.create({
|
||||||
@@ -1163,7 +1477,8 @@ export class HomePage implements OnDestroy {
|
|||||||
this.showValidati() ||
|
this.showValidati() ||
|
||||||
this.showNonValidati() ||
|
this.showNonValidati() ||
|
||||||
this.playlistService.activeListName() !== null ||
|
this.playlistService.activeListName() !== null ||
|
||||||
this.searchQuery() !== '';
|
this.searchQuery() !== '' ||
|
||||||
|
this.hasAdvancedSearchParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldShowSubSectionToolbar(): boolean {
|
shouldShowSubSectionToolbar(): boolean {
|
||||||
@@ -1189,6 +1504,7 @@ export class HomePage implements OnDestroy {
|
|||||||
this.playlistService.activeListName.set(null);
|
this.playlistService.activeListName.set(null);
|
||||||
this.playlistService.activePlaylistId.set(null);
|
this.playlistService.activePlaylistId.set(null);
|
||||||
this.searchQuery.set('');
|
this.searchQuery.set('');
|
||||||
|
this.clearAdvancedSearch();
|
||||||
this.activeFilterType.set(null);
|
this.activeFilterType.set(null);
|
||||||
this.limit.set(10);
|
this.limit.set(10);
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1535,11 @@ export class HomePage implements OnDestroy {
|
|||||||
this.playlistService.activeListIds.set(pl.ids);
|
this.playlistService.activeListIds.set(pl.ids);
|
||||||
this.playlistService.activeListName.set(pl.name);
|
this.playlistService.activeListName.set(pl.name);
|
||||||
this.playlistService.activePlaylistId.set(pl.id);
|
this.playlistService.activePlaylistId.set(pl.id);
|
||||||
|
if (pl.isRemote || pl.id?.startsWith('remote_')) {
|
||||||
|
this.playlistService.checkForRemotePlaylistUpdates();
|
||||||
|
}
|
||||||
|
this.isAdvancedSearchOpen.set(false);
|
||||||
|
this.isFilterCardOpen.set(false);
|
||||||
// Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva
|
// Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva
|
||||||
this.limit.set(50);
|
this.limit.set(50);
|
||||||
}
|
}
|
||||||
@@ -1356,6 +1677,7 @@ export class HomePage implements OnDestroy {
|
|||||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.isBlackOverlayDismissed.set(false);
|
||||||
this.youtubePlayerService.setMediaSessionCallbacks(
|
this.youtubePlayerService.setMediaSessionCallbacks(
|
||||||
() => this.playNextPreview(),
|
() => this.playNextPreview(),
|
||||||
() => this.playPrevPreview()
|
() => this.playPrevPreview()
|
||||||
@@ -1386,6 +1708,19 @@ export class HomePage implements OnDestroy {
|
|||||||
this.playlistService.autoPlayPlaylist.set(false);
|
this.playlistService.autoPlayPlaylist.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pauseVideo(event: Event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
|
this.youtubePlayerService.pause();
|
||||||
|
this.isBlackOverlayDismissed.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayPause() {
|
||||||
|
if (!this.youtubePlayerService.isPlaying()) {
|
||||||
|
this.isBlackOverlayDismissed.set(false);
|
||||||
|
}
|
||||||
|
this.youtubePlayerService.togglePlayPause();
|
||||||
|
}
|
||||||
|
|
||||||
playNextPreview(event?: Event) {
|
playNextPreview(event?: Event) {
|
||||||
if (event) event.stopPropagation();
|
if (event) event.stopPropagation();
|
||||||
const currentId = this.youtubePlayerService.currentCantoId();
|
const currentId = this.youtubePlayerService.currentCantoId();
|
||||||
@@ -1605,18 +1940,21 @@ export class HomePage implements OnDestroy {
|
|||||||
// Automatically save to the device!
|
// Automatically save to the device!
|
||||||
this.playlistService.savePlaylist(playlistName, ids);
|
this.playlistService.savePlaylist(playlistName, ids);
|
||||||
|
|
||||||
// Activate the playlist filter and open the first song!
|
// Activate the playlist filter
|
||||||
this.activeFilterType.set('playlist');
|
this.activeFilterType.set('playlist');
|
||||||
if (ids.length > 0) {
|
|
||||||
this.goToCanto(ids[0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleVoiceSearch() {
|
toggleVoiceSearch(target: 'global' | 'title' | 'author' | 'text' | 'number' = 'global') {
|
||||||
if (this.audioEngine.isSearching()) {
|
if (this.audioEngine.isSearching()) {
|
||||||
|
const currentTarget = this.activeVoiceField();
|
||||||
this.audioEngine.stopSearchRecognition();
|
this.audioEngine.stopSearchRecognition();
|
||||||
|
if (currentTarget !== target) {
|
||||||
|
this.activeVoiceField.set(target);
|
||||||
|
this.audioEngine.startSearchRecognition();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.activeVoiceField.set(target);
|
||||||
this.audioEngine.startSearchRecognition();
|
this.audioEngine.startSearchRecognition();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1647,6 +1985,9 @@ export class HomePage implements OnDestroy {
|
|||||||
const songSettings = pl ? pl.songSettings : undefined;
|
const songSettings = pl ? pl.songSettings : undefined;
|
||||||
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
||||||
|
|
||||||
|
// Azzoppa / azzera il filtro cerca libera quando si salva
|
||||||
|
this.searchQuery.set('');
|
||||||
|
|
||||||
const toast = await this.toastCtrl.create({
|
const toast = await this.toastCtrl.create({
|
||||||
message: 'Playlist salvata!',
|
message: 'Playlist salvata!',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
@@ -1684,18 +2025,57 @@ export class HomePage implements OnDestroy {
|
|||||||
|
|
||||||
isComunitaPlaylist(): boolean {
|
isComunitaPlaylist(): boolean {
|
||||||
const id = this.playlistService.activePlaylistId();
|
const id = this.playlistService.activePlaylistId();
|
||||||
return !!id && id.startsWith('comunita_');
|
if (id && id.startsWith('comunita_')) return true;
|
||||||
|
const name = this.playlistService.activeListName();
|
||||||
|
if (name) {
|
||||||
|
const pl = this.playlistService.allPlaylists().find(p => p.name === name);
|
||||||
|
return !!pl?.isComunita;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
isRemotePlaylist(): boolean {
|
isRemotePlaylist(): boolean {
|
||||||
const id = this.playlistService.activePlaylistId();
|
const id = this.playlistService.activePlaylistId();
|
||||||
return !!id && id.startsWith('remote_');
|
if (id && id.startsWith('remote_')) return true;
|
||||||
|
const name = this.playlistService.activeListName();
|
||||||
|
if (name) {
|
||||||
|
const pl = this.playlistService.allPlaylists().find(p => p.name === name);
|
||||||
|
return !!pl?.isRemote;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshActiveRemotePlaylist() {
|
||||||
|
const loading = await this.loadingCtrl.create({
|
||||||
|
message: 'Aggiornamento playlist...'
|
||||||
|
});
|
||||||
|
await loading.present();
|
||||||
|
try {
|
||||||
|
await this.playlistService.refreshRemotePlaylist();
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'Playlist aggiornata da remoto!',
|
||||||
|
duration: 2000,
|
||||||
|
color: 'success'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
} catch (e) {
|
||||||
|
const alert = await this.alertCtrl.create({
|
||||||
|
header: 'Errore',
|
||||||
|
message: 'Impossibile aggiornare la playlist da remoto.',
|
||||||
|
buttons: ['OK']
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
} finally {
|
||||||
|
await loading.dismiss();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isActivePlaylistSaved(): boolean {
|
isActivePlaylistSaved(): boolean {
|
||||||
const id = this.playlistService.activePlaylistId();
|
const id = this.playlistService.activePlaylistId();
|
||||||
if (!id) return false;
|
if (id) return this.playlistService.playlists().some(p => p.id === id);
|
||||||
return this.playlistService.playlists().some(p => p.id === id);
|
const name = this.playlistService.activeListName();
|
||||||
|
if (name) return this.playlistService.playlists().some(p => p.name === name);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSpecialList(event?: Event) {
|
clearSpecialList(event?: Event) {
|
||||||
@@ -1725,6 +2105,105 @@ export class HomePage implements OnDestroy {
|
|||||||
this.playlistService.sharePlaylistQR(ids, name, songSettings);
|
this.playlistService.sharePlaylistQR(ids, name, songSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async printActivePlaylist() {
|
||||||
|
const ids = this.playlistService.activeListIds();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const allCanti = this.cantiService.canti();
|
||||||
|
const myCantiList = this.myCantiService.myCanti();
|
||||||
|
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
|
||||||
|
const communityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive();
|
||||||
|
|
||||||
|
const songs = ids.map(id => {
|
||||||
|
let found: any = null;
|
||||||
|
if (communityActive) {
|
||||||
|
found = comunitaCantiPers.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = allCanti.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = myCantiList.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = comunitaCantiPers.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}).filter(c => !!c);
|
||||||
|
|
||||||
|
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.playlistService.activeListName() || 'Playlist';
|
||||||
|
this.playlistService.printPlaylist(name, songs, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Testo e Accordi',
|
||||||
|
handler: () => {
|
||||||
|
const name = this.playlistService.activeListName() || 'Playlist';
|
||||||
|
this.playlistService.printPlaylist(name, songs, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Annulla',
|
||||||
|
role: 'cancel'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
async cloneActivePlaylist() {
|
||||||
|
const id = this.playlistService.activePlaylistId();
|
||||||
|
const currentName = this.playlistService.activeListName() || 'Playlist';
|
||||||
|
const activeIds = this.playlistService.activeListIds();
|
||||||
|
|
||||||
|
const allPls = this.playlistService.allPlaylists();
|
||||||
|
const activePl = allPls.find(p => p.id === id);
|
||||||
|
const songSettings = activePl ? activePl.songSettings : undefined;
|
||||||
|
|
||||||
|
const alert = await this.alertCtrl.create({
|
||||||
|
header: 'Clona Playlist',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Nome della nuova playlist',
|
||||||
|
value: `${currentName} (Copia)`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Annulla',
|
||||||
|
role: 'cancel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Clona',
|
||||||
|
handler: async (data) => {
|
||||||
|
if (data.name) {
|
||||||
|
await this.playlistService.clonePlaylist(data.name, [...activeIds], songSettings);
|
||||||
|
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: `Playlist clonata nel profilo: ${data.name}`,
|
||||||
|
duration: 2500,
|
||||||
|
color: 'success'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
|
||||||
async importPlaylist() {
|
async importPlaylist() {
|
||||||
const modal = await this.modalCtrl.create({
|
const modal = await this.modalCtrl.create({
|
||||||
component: QrScannerComponent
|
component: QrScannerComponent
|
||||||
@@ -1835,6 +2314,44 @@ export class HomePage implements OnDestroy {
|
|||||||
return index !== -1 ? index + 1 : 0;
|
return index !== -1 ? index + 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSongTonality(canto: any): string | null {
|
||||||
|
if (!canto) return null;
|
||||||
|
const baseKey = this.lyricsParser.deduceTonality(canto.accordi || canto.testo || '');
|
||||||
|
if (!baseKey) return null;
|
||||||
|
|
||||||
|
let semitones = 0;
|
||||||
|
const activePlaylistId = this.playlistService.activePlaylistId();
|
||||||
|
if (activePlaylistId) {
|
||||||
|
let playlistSongSetting: any = null;
|
||||||
|
if (activePlaylistId.startsWith('remote_')) {
|
||||||
|
const pl = this.playlistService.remotePlaylist();
|
||||||
|
if (pl && pl.songSettings && pl.songSettings[canto.id]) {
|
||||||
|
playlistSongSetting = pl.songSettings[canto.id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId);
|
||||||
|
if (pl && pl.songSettings && pl.songSettings[canto.id]) {
|
||||||
|
playlistSongSetting = pl.songSettings[canto.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (playlistSongSetting && playlistSongSetting.tonalita !== undefined) {
|
||||||
|
semitones = playlistSongSetting.tonalita;
|
||||||
|
}
|
||||||
|
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||||
|
const settings = this.comunitaService.comunitaCantiSettings();
|
||||||
|
const songSetting = settings.find(s => s.id_canti === canto.id_canti || s.id_canti === Number(canto.id));
|
||||||
|
if (songSetting && songSetting.tonalita !== undefined) {
|
||||||
|
semitones = songSetting.tonalita;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semitones !== 0) {
|
||||||
|
return this.lyricsParser.transposeChord(baseKey, semitones);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseKey;
|
||||||
|
}
|
||||||
|
|
||||||
toggleComunitaFilter() {
|
toggleComunitaFilter() {
|
||||||
if (this.comunitaService.comunitaCode()) {
|
if (this.comunitaService.comunitaCode()) {
|
||||||
this.comunitaService.isFilterActive.update(v => !v);
|
this.comunitaService.isFilterActive.update(v => !v);
|
||||||
@@ -1876,6 +2393,36 @@ export class HomePage implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private panelStartX: number = 0;
|
||||||
|
private panelStartY: number = 0;
|
||||||
|
|
||||||
|
onPanelTouchStart(event: TouchEvent) {
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
this.panelStartX = event.touches[0].clientX;
|
||||||
|
this.panelStartY = event.touches[0].clientY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPanelTouchEnd(event: TouchEvent, panelType: 'advancedSearch' | 'playlistCard' | 'filterCard') {
|
||||||
|
if (event.changedTouches.length === 1) {
|
||||||
|
const endX = event.changedTouches[0].clientX;
|
||||||
|
const endY = event.changedTouches[0].clientY;
|
||||||
|
const diffX = endX - this.panelStartX;
|
||||||
|
const diffY = endY - this.panelStartY;
|
||||||
|
|
||||||
|
// Swipe UP: vertical movement upwards (diffY negative, e.g. < -40) and larger than horizontal diff
|
||||||
|
if (diffY < -40 && Math.abs(diffY) > Math.abs(diffX)) {
|
||||||
|
if (panelType === 'advancedSearch') {
|
||||||
|
this.isAdvancedSearchOpen.set(false);
|
||||||
|
} else if (panelType === 'playlistCard') {
|
||||||
|
this.isPlaylistCardOpen.set(false);
|
||||||
|
} else if (panelType === 'filterCard') {
|
||||||
|
this.isFilterCardOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async navigateMassDate(direction: number) {
|
async navigateMassDate(direction: number) {
|
||||||
const list = this.cantiLettureService.availableMasses();
|
const list = this.cantiLettureService.availableMasses();
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
@@ -1926,5 +2473,49 @@ export class HomePage implements OnDestroy {
|
|||||||
}
|
}
|
||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatSeconds(seconds: number | undefined | null): string {
|
||||||
|
if (seconds === undefined || seconds === null || isNaN(seconds)) {
|
||||||
|
return '0:00';
|
||||||
|
}
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enterFullscreen() {
|
||||||
|
try {
|
||||||
|
const docEl = document.documentElement;
|
||||||
|
if (docEl.requestFullscreen) {
|
||||||
|
await docEl.requestFullscreen();
|
||||||
|
} else if ((docEl as any).webkitRequestFullscreen) {
|
||||||
|
await (docEl as any).webkitRequestFullscreen();
|
||||||
|
} else if ((docEl as any).msRequestFullscreen) {
|
||||||
|
await (docEl as any).msRequestFullscreen();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[HomePage] Failed to enter fullscreen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exitFullscreen() {
|
||||||
|
try {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} else if ((document as any).webkitExitFullscreen) {
|
||||||
|
if ((document as any).webkitFullscreenElement) {
|
||||||
|
await (document as any).webkitExitFullscreen();
|
||||||
|
}
|
||||||
|
} else if ((document as any).msExitFullscreen) {
|
||||||
|
if ((document as any).msFullscreenElement) {
|
||||||
|
await (document as any).msExitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[HomePage] Failed to exit fullscreen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
min-height: 0.5em;
|
min-height: 0.5em;
|
||||||
font-family: 'Outfit', sans-serif;
|
font-family: 'Outfit', sans-serif;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
padding-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-line .chord {
|
.active-line .chord {
|
||||||
|
|||||||
@@ -1,37 +1,70 @@
|
|||||||
<ion-header [translucent]="true" class="ion-no-border">
|
<ion-header [translucent]="true" class="ion-no-border">
|
||||||
<ion-toolbar class="bg-gradient top-toolbar">
|
<ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
|
||||||
<ion-buttons slot="start">
|
<div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
|
||||||
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
|
|
||||||
</ion-buttons>
|
<!-- Row 1: Number + Title + Settings -->
|
||||||
<ion-title class="outfit-font wrapped-title">
|
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
|
||||||
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
|
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
|
||||||
<span class="canto-number" *ngIf="canto()?.id_canti">
|
[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 }}
|
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
|
||||||
</span>
|
</span>
|
||||||
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
<span class="canto-title"
|
||||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
[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>{{ canto()?.titolo || 'Player' }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<ion-button routerLink="/settings" class="settings-btn" fill="clear" style="flex-shrink: 0; margin: 0; --color: var(--ion-color-secondary);">
|
||||||
|
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</ion-title>
|
|
||||||
<ion-buttons slot="end">
|
<!-- Row 2: Author -->
|
||||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
<div *ngIf="!isLandscapeActive()"
|
||||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
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>
|
</div>
|
||||||
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
|
||||||
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
<!-- Row 3: Badges only -->
|
||||||
</ion-button>
|
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
|
||||||
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
style="margin-top: 2px; width: 100%;"
|
||||||
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||||
</ion-button>
|
<div style="display: flex; align-items: center; gap: inherit;">
|
||||||
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||||
<ion-icon slot="icon-only"
|
<span *ngIf="canto()?.durata" class="song-duration-badge"
|
||||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
[style.fontSize.rem]="0.75 * fontSize()"
|
||||||
[color]="showChords() ? 'secondary' : 'medium'"
|
[style.height.px]="26 * fontSize()"
|
||||||
style="font-size: 1.3rem;">
|
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>
|
<ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||||
</ion-button>
|
{{ canto()?.durata }}
|
||||||
</ion-buttons>
|
</span>
|
||||||
|
<span *ngIf="canto()?.bpm" class="song-bpm-badge"
|
||||||
|
[style.fontSize.rem]="0.75 * fontSize()"
|
||||||
|
[style.height.px]="26 * fontSize()"
|
||||||
|
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||||
|
<ion-icon name="pulse-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||||
|
{{ canto()?.bpm }} BPM
|
||||||
|
</span>
|
||||||
|
<span *ngIf="tonality()" class="song-tonality-badge"
|
||||||
|
[style.fontSize.rem]="0.75 * fontSize()"
|
||||||
|
[style.height.px]="26 * fontSize()"
|
||||||
|
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||||
|
<ion-icon name="musical-note-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||||
|
{{ tonality() }}
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
|
||||||
<!-- Audio Toolbar Removed -->
|
<!-- Audio Toolbar Removed -->
|
||||||
@@ -105,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sections rendering -->
|
<!-- Sections rendering -->
|
||||||
<div class="lyrics-view">
|
<div class="lyrics-view" (click)="handleLyricsClick($event)">
|
||||||
<div *ngFor="let section of parsedSections(); let si = index"
|
<div *ngFor="let section of parsedSections(); let si = index"
|
||||||
class="section"
|
class="section"
|
||||||
[class.chorus]="section.type === 'chorus'"
|
[class.chorus]="section.type === 'chorus'"
|
||||||
@@ -145,6 +178,9 @@
|
|||||||
<ion-toolbar class="global-player-toolbar glass">
|
<ion-toolbar class="global-player-toolbar glass">
|
||||||
<div class="player-content">
|
<div class="player-content">
|
||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="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-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
|
||||||
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -154,14 +190,19 @@
|
|||||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
</ion-button>
|
</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
|
<ion-range
|
||||||
[min]="0"
|
[min]="0"
|
||||||
[max]="youtubePlayerService.videoDuration()"
|
[max]="youtubePlayerService.videoDuration()"
|
||||||
[value]="youtubePlayerService.videoProgress()"
|
[value]="youtubePlayerService.videoProgress()"
|
||||||
(ionChange)="onSeek($event)"
|
(ionChange)="onSeek($event)"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
class="global-range">
|
class="global-range"
|
||||||
|
style="margin: 0; padding: 0 4px;">
|
||||||
</ion-range>
|
</ion-range>
|
||||||
|
|
||||||
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #noAudioMsg>
|
<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);">
|
<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);">
|
||||||
@@ -204,7 +245,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation (Avanzamento Karaoke e Riparti da inizio) -->
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<ion-button fill="clear" size="small" (click)="restart()">
|
<ion-button fill="clear" size="small" (click)="restart()">
|
||||||
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
|
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
|
||||||
@@ -217,6 +258,28 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</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) -->
|
<!-- Transposition (Only in chords mode) -->
|
||||||
<div class="group" *ngIf="showChords()">
|
<div class="group" *ngIf="showChords()">
|
||||||
<ion-button fill="clear" size="small" (click)="transposeDown()">
|
<ion-button fill="clear" size="small" (click)="transposeDown()">
|
||||||
@@ -224,7 +287,8 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
<div class="transpose-indicator">
|
<div class="transpose-indicator">
|
||||||
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
||||||
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
|
<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>
|
</div>
|
||||||
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
||||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||||
@@ -258,7 +322,36 @@
|
|||||||
</ion-footer>
|
</ion-footer>
|
||||||
|
|
||||||
<!-- Black Screen Overlay -->
|
<!-- Black Screen Overlay -->
|
||||||
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()"></div>
|
<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>
|
||||||
|
|
||||||
|
<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) -->
|
<!-- 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>
|
<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%);
|
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 {
|
.outfit-font {
|
||||||
font-family: 'Outfit', sans-serif;
|
font-family: 'Outfit', sans-serif;
|
||||||
}
|
}
|
||||||
@@ -20,40 +27,71 @@
|
|||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canto-number {
|
||||||
|
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;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
&:active { transform: scale(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--ion-color-secondary);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--ion-color-secondary);
|
||||||
|
box-shadow: 0 0 10px rgba(var(--ion-color-secondary-rgb), 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.title-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
font-size: 1.15rem; // Fisso: non influenzato dallo zoom del testo
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
.canto-number {
|
.song-author {
|
||||||
font-size: 0.85em;
|
font-size: 0.75rem;
|
||||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
padding: 2px 10px;
|
font-weight: 400;
|
||||||
display: inline-flex;
|
display: inline-block;
|
||||||
align-items: center;
|
font-family: 'Outfit', sans-serif;
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
&:active { transform: scale(0.9); }
|
|
||||||
}
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background: var(--ion-color-secondary);
|
|
||||||
color: #000;
|
|
||||||
border-color: var(--ion-color-secondary);
|
|
||||||
box-shadow: 0 0 10px rgba(var(--ion-color-secondary-rgb), 0.4);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-text {
|
.title-text {
|
||||||
@@ -61,6 +99,102 @@
|
|||||||
text-align: left;
|
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
|
// Content and Lyrics
|
||||||
@@ -105,25 +239,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
.play-btn {
|
.play-btn, .skip-btn, .close-btn {
|
||||||
--padding-start: 0;
|
--padding-start: 0;
|
||||||
--padding-end: 0;
|
--padding-end: 0;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
width: 44px;
|
width: 40px;
|
||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
font-size: 2.2rem;
|
font-size: 1.6rem;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-btn, .close-btn {
|
|
||||||
--padding-start: 0;
|
|
||||||
--padding-end: 0;
|
|
||||||
height: 36px;
|
|
||||||
width: 36px;
|
|
||||||
|
|
||||||
ion-icon {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +267,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.chorus {
|
&.chorus {
|
||||||
@@ -160,21 +283,22 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.25rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyric-line {
|
.lyric-line {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.4rem;
|
||||||
line-height: 1.6;
|
line-height: 1.35;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-height: 1.5em;
|
min-height: 1.5em;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
@@ -200,6 +324,7 @@
|
|||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
margin-bottom: -0.2em;
|
margin-bottom: -0.2em;
|
||||||
|
padding-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg-text {
|
.seg-text {
|
||||||
@@ -730,6 +855,10 @@ ion-content.full-screen-content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:host-context(body.high-contrast) {
|
:host-context(body.high-contrast) {
|
||||||
|
.title-main .song-author {
|
||||||
|
color: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.slim-toolbar {
|
.slim-toolbar {
|
||||||
--background: #ffffff !important;
|
--background: #ffffff !important;
|
||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
@@ -890,11 +1019,97 @@ ion-content.full-screen-content {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
right: 0;
|
||||||
height: 100vh;
|
bottom: 0;
|
||||||
background-color: #000000;
|
width: 100%;
|
||||||
z-index: 99999;
|
height: 100%;
|
||||||
|
background-color: #000000 !important;
|
||||||
|
z-index: 9999999 !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
cursor: pointer;
|
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 {
|
.landscape-playlist-btn {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { FaceDetectorService } from '../../services/face-detector.service';
|
|||||||
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||||
public canto = signal<Canto | null>(null);
|
public canto = signal<Canto | null>(null);
|
||||||
public activeSongId = signal<string | null>(null);
|
public activeSongId = signal<string | null>(null);
|
||||||
|
public isTitleWrapped = signal<boolean>(false);
|
||||||
|
|
||||||
@HostListener('window:keydown', ['$event'])
|
@HostListener('window:keydown', ['$event'])
|
||||||
handleKeyDown(event: KeyboardEvent) {
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
@@ -33,12 +34,39 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'ArrowUp') {
|
if (event.key === 'ArrowUp' || event.key === 'MediaTrackPrevious') {
|
||||||
this.prevSong();
|
this.prevSong();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === 'ArrowDown') {
|
} else if (event.key === 'ArrowDown' || event.key === 'MediaTrackNext') {
|
||||||
this.nextSong();
|
this.nextSong();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'MediaPlayPause') {
|
||||||
|
this.toggleAudio();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'PageUp' || event.key === 'ArrowLeft') {
|
||||||
|
this.prev(true);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'PageDown' || event.key === 'ArrowRight') {
|
||||||
|
this.next(false, true);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLyricsClick(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target && (target.closest('button') || target.closest('ion-button') || target.closest('.side-indicator') || target.closest('.autoscroll-indicator') || target.closest('ion-icon'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clickX = event.clientX;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (clickX > width / 2) {
|
||||||
|
this.next(false, true);
|
||||||
|
} else {
|
||||||
|
this.prev(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +133,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
public transposeAmount = signal<number>(0);
|
public transposeAmount = signal<number>(0);
|
||||||
|
|
||||||
|
public tonality = computed(() => {
|
||||||
|
const song = this.canto();
|
||||||
|
if (!song) return null;
|
||||||
|
const baseKey = this.lyricsParser.deduceTonality(song.accordi || song.testo || '');
|
||||||
|
if (!baseKey) return null;
|
||||||
|
|
||||||
|
const amount = this.transposeAmount();
|
||||||
|
if (amount === 0) return baseKey;
|
||||||
|
return this.lyricsParser.transposeChord(baseKey, amount);
|
||||||
|
});
|
||||||
|
|
||||||
public currentLineIndex = signal<number>(0);
|
public currentLineIndex = signal<number>(0);
|
||||||
public math = Math;
|
public math = Math;
|
||||||
public youtubePlayerService = inject(YoutubePlayerService);
|
public youtubePlayerService = inject(YoutubePlayerService);
|
||||||
@@ -152,7 +191,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
public faceDetector = inject(FaceDetectorService);
|
public faceDetector = inject(FaceDetectorService);
|
||||||
private meta = inject(Meta);
|
private meta = inject(Meta);
|
||||||
|
|
||||||
public enableCameraNavigation = signal<boolean>(false);
|
public enableCameraNavigation = computed(() => this.settingsService.cameraNavigationActive());
|
||||||
|
|
||||||
private songStartTime: number = 0;
|
private songStartTime: number = 0;
|
||||||
|
|
||||||
@@ -189,6 +228,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.playlistService.autoPlayPlaylist()) {
|
if (this.playlistService.autoPlayPlaylist()) {
|
||||||
setTimeout(() => this.initPlayer(c.id), 500);
|
setTimeout(() => this.initPlayer(c.id), 500);
|
||||||
}
|
}
|
||||||
|
this.isTitleWrapped.set(false);
|
||||||
|
setTimeout(() => this.checkTitleWrap(), 150);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,13 +327,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (playlistSongSetting) {
|
if (playlistSongSetting) {
|
||||||
this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0);
|
this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0);
|
||||||
this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2);
|
this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2);
|
||||||
if (playlistSongSetting.zoom !== undefined) {
|
const gZoom = this.settingsService.globalZoom();
|
||||||
this.fontSize.set(playlistSongSetting.zoom);
|
this.fontSize.set(gZoom);
|
||||||
this.portraitFontSize = playlistSongSetting.zoom;
|
this.portraitFontSize = gZoom;
|
||||||
} else {
|
|
||||||
this.fontSize.set(1.0);
|
|
||||||
this.portraitFontSize = 1.0;
|
|
||||||
}
|
|
||||||
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||||
const settings = this.comunitaService.comunitaCantiSettings();
|
const settings = this.comunitaService.comunitaCantiSettings();
|
||||||
const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id));
|
const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id));
|
||||||
@@ -312,21 +349,24 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.transposeAmount.set(0);
|
this.transposeAmount.set(0);
|
||||||
this.autoscrollSpeed.set(2);
|
this.autoscrollSpeed.set(2);
|
||||||
}
|
}
|
||||||
this.fontSize.set(1.0);
|
const gZoom = this.settingsService.globalZoom();
|
||||||
this.portraitFontSize = 1.0;
|
this.fontSize.set(gZoom);
|
||||||
|
this.portraitFontSize = gZoom;
|
||||||
} else {
|
} else {
|
||||||
this.transposeAmount.set(0);
|
this.transposeAmount.set(0);
|
||||||
this.autoscrollSpeed.set(2);
|
this.autoscrollSpeed.set(2);
|
||||||
this.fontSize.set(1.0);
|
const gZoom = this.settingsService.globalZoom();
|
||||||
this.portraitFontSize = 1.0;
|
this.fontSize.set(gZoom);
|
||||||
|
this.portraitFontSize = gZoom;
|
||||||
}
|
}
|
||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
// Fullscreen control in landscape mode
|
// Fullscreen control in landscape mode or when black screen is active
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const isLandscape = this.isLandscapeActive();
|
const isLandscape = this.isLandscapeActive();
|
||||||
|
const isBlack = this.isBlackScreen();
|
||||||
|
|
||||||
if (isLandscape) {
|
if (isLandscape || isBlack) {
|
||||||
this.enterFullscreen();
|
this.enterFullscreen();
|
||||||
} else {
|
} else {
|
||||||
this.exitFullscreen();
|
this.exitFullscreen();
|
||||||
@@ -340,6 +380,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
onResize(event: any) {
|
onResize(event: any) {
|
||||||
this.windowLandscape.set(window.innerWidth > window.innerHeight);
|
this.windowLandscape.set(window.innerWidth > window.innerHeight);
|
||||||
this.checkLandscapeZoom();
|
this.checkLandscapeZoom();
|
||||||
|
this.checkTitleWrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLandscape(): boolean {
|
private isLandscape(): boolean {
|
||||||
@@ -462,8 +503,26 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
checkTitleWrap() {
|
||||||
|
const el = document.querySelector('.canto-title') as HTMLElement;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
this.isTitleWrapped.set(false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (el.scrollHeight > 30) {
|
||||||
|
this.isTitleWrapped.set(true);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.checkLandscapeZoom();
|
this.checkLandscapeZoom();
|
||||||
|
this.checkTitleWrap();
|
||||||
|
if (this.settingsService.cameraNavigationActive()) {
|
||||||
|
this.initCameraTracking();
|
||||||
|
}
|
||||||
const gestureX = this.gestureCtrl.create({
|
const gestureX = this.gestureCtrl.create({
|
||||||
el: this.el.nativeElement,
|
el: this.el.nativeElement,
|
||||||
direction: 'x',
|
direction: 'x',
|
||||||
@@ -549,6 +608,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.fontSize.set(limited);
|
this.fontSize.set(limited);
|
||||||
if (!this.isLandscape()) {
|
if (!this.isLandscape()) {
|
||||||
this.portraitFontSize = limited;
|
this.portraitFontSize = limited;
|
||||||
|
this.settingsService.globalZoom.set(limited);
|
||||||
}
|
}
|
||||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||||
}
|
}
|
||||||
@@ -587,8 +647,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
activePlaylistId,
|
activePlaylistId,
|
||||||
c.id,
|
c.id,
|
||||||
this.transposeAmount(),
|
this.transposeAmount(),
|
||||||
this.autoscrollSpeed(),
|
this.autoscrollSpeed()
|
||||||
this.fontSize()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +675,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.fontSize.set(limited);
|
this.fontSize.set(limited);
|
||||||
if (!this.isLandscape()) {
|
if (!this.isLandscape()) {
|
||||||
this.portraitFontSize = limited;
|
this.portraitFontSize = limited;
|
||||||
|
this.settingsService.globalZoom.set(limited);
|
||||||
}
|
}
|
||||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||||
this.updatePlaylistSettings();
|
this.updatePlaylistSettings();
|
||||||
@@ -630,6 +690,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.fontSize.set(target);
|
this.fontSize.set(target);
|
||||||
if (!this.isLandscape()) {
|
if (!this.isLandscape()) {
|
||||||
this.portraitFontSize = target;
|
this.portraitFontSize = target;
|
||||||
|
this.settingsService.globalZoom.set(target);
|
||||||
}
|
}
|
||||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||||
this.updatePlaylistSettings();
|
this.updatePlaylistSettings();
|
||||||
@@ -658,6 +719,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.youtubePlayerService.seekTo(event.detail.value);
|
this.youtubePlayerService.seekTo(event.detail.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goHome() {
|
||||||
|
this.router.navigate(['/home']);
|
||||||
|
}
|
||||||
|
|
||||||
restart() {
|
restart() {
|
||||||
this.currentLineIndex.set(0);
|
this.currentLineIndex.set(0);
|
||||||
this.youtubePlayerService.seekTo(0);
|
this.youtubePlayerService.seekTo(0);
|
||||||
@@ -915,11 +980,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextSong() {
|
private getSequenceList(): string[] {
|
||||||
let list = this.playlistService.activeListIds();
|
const currentId = this.canto()?.id;
|
||||||
if (list.length === 0) {
|
|
||||||
list = this.cantiService.canti().map(c => c.id);
|
// 1. Homepage filtered list (includes current sorting like Top Ten, Suggeriti, and any active playlist + additional filters)
|
||||||
|
const filtered = this.playlistService.filteredListIds();
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
if (!currentId || filtered.includes(currentId)) {
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Active playlist raw list if present and contains current song
|
||||||
|
const active = this.playlistService.activeListIds();
|
||||||
|
if (active.length > 0) {
|
||||||
|
if (!currentId || active.includes(currentId)) {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Default fallback: all canti
|
||||||
|
return this.cantiService.canti().map(c => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSong() {
|
||||||
|
const list = this.getSequenceList();
|
||||||
|
|
||||||
const currentId = this.canto()?.id;
|
const currentId = this.canto()?.id;
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
@@ -930,17 +1015,14 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.isLandscapeActive()) {
|
if (this.isLandscapeActive()) {
|
||||||
this.isBlackScreen.set(true);
|
this.isBlackScreen.set(true);
|
||||||
}
|
}
|
||||||
this.router.navigate(['/player'], { queryParams: { id: nextId } });
|
this.router.navigate(['/player'], { queryParams: { id: nextId }, replaceUrl: true });
|
||||||
} else {
|
} else {
|
||||||
this.playlistService.autoPlayPlaylist.set(false);
|
this.playlistService.autoPlayPlaylist.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevSong() {
|
prevSong() {
|
||||||
let list = this.playlistService.activeListIds();
|
const list = this.getSequenceList();
|
||||||
if (list.length === 0) {
|
|
||||||
list = this.cantiService.canti().map(c => c.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentId = this.canto()?.id;
|
const currentId = this.canto()?.id;
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
@@ -951,7 +1033,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.isLandscapeActive()) {
|
if (this.isLandscapeActive()) {
|
||||||
this.isBlackScreen.set(true);
|
this.isBlackScreen.set(true);
|
||||||
}
|
}
|
||||||
this.router.navigate(['/player'], { queryParams: { id: prevId } });
|
this.router.navigate(['/player'], { queryParams: { id: prevId }, replaceUrl: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,7 +1041,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
queryParams: { id },
|
queryParams: { id },
|
||||||
queryParamsHandling: 'merge'
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true
|
||||||
});
|
});
|
||||||
this.restart();
|
this.restart();
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1133,27 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteMyCanto() {
|
||||||
|
const c = this.canto();
|
||||||
|
if (!c || !c.id.startsWith('my_')) return;
|
||||||
|
const alert = await this.alertCtrl.create({
|
||||||
|
header: 'Elimina Canto',
|
||||||
|
message: 'Sei sicuro di voler eliminare questo canto dai tuoi brani personali?',
|
||||||
|
buttons: [
|
||||||
|
{ text: 'Annulla', role: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Elimina',
|
||||||
|
role: 'destructive',
|
||||||
|
handler: () => {
|
||||||
|
this.myCantiService.deleteCanto(c.id);
|
||||||
|
this.router.navigate(['/home']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
|
||||||
async shareCanto() {
|
async shareCanto() {
|
||||||
const c = this.canto();
|
const c = this.canto();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
@@ -1072,8 +1176,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// Try to fetch YouTube thumbnail first if available
|
// Try to fetch YouTube thumbnail first if available
|
||||||
if (thumbUrl) {
|
if (thumbUrl) {
|
||||||
try {
|
try {
|
||||||
|
// Use images.weserv.nl as a CORS proxy to bypass YouTube's CORS policy
|
||||||
|
const proxiedUrl = `https://images.weserv.nl/?url=${encodeURIComponent(thumbUrl)}`;
|
||||||
const response = await Promise.race([
|
const response = await Promise.race([
|
||||||
fetch(thumbUrl),
|
fetch(proxiedUrl),
|
||||||
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
|
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
|
||||||
]);
|
]);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1083,10 +1189,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
|
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// If no YouTube thumb is available, fallback to local canticristiani logo (favicon)
|
||||||
// If no YouTube thumb or fetch failed, fallback to local canticristiani logo (favicon)
|
|
||||||
if (!fileToShare) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('assets/icon/favicon.png');
|
const response = await fetch('assets/icon/favicon.png');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1106,7 +1210,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
url: shareLink
|
url: shareLink
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) {
|
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
if (!isMac && fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) {
|
||||||
shareDataObj.files = [fileToShare];
|
shareDataObj.files = [fileToShare];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1235,35 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async printCanto() {
|
||||||
|
const c = this.canto();
|
||||||
|
if (!c) return;
|
||||||
|
|
||||||
|
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: () => {
|
||||||
|
this.playlistService.printPlaylist(c.titolo, [c], false, { [c.id || c.id_canti]: this.transposeAmount() });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Testo e Accordi',
|
||||||
|
handler: () => {
|
||||||
|
this.playlistService.printPlaylist(c.titolo, [c], true, { [c.id || c.id_canti]: this.transposeAmount() });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Annulla',
|
||||||
|
role: 'cancel'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
|
||||||
private initPlayer(id: string) {
|
private initPlayer(id: string) {
|
||||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||||
return;
|
return;
|
||||||
@@ -1234,7 +1370,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleCameraNavigation() {
|
toggleCameraNavigation() {
|
||||||
if (this.enableCameraNavigation()) {
|
if (this.settingsService.cameraNavigationActive()) {
|
||||||
this.stopCameraNavigation();
|
this.stopCameraNavigation();
|
||||||
} else {
|
} else {
|
||||||
this.startCameraNavigation();
|
this.startCameraNavigation();
|
||||||
@@ -1242,8 +1378,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startCameraNavigation() {
|
startCameraNavigation() {
|
||||||
this.enableCameraNavigation.set(true);
|
this.settingsService.cameraNavigationActive.set(true);
|
||||||
|
this.initCameraTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
initCameraTracking() {
|
||||||
|
if (!this.settingsService.cameraNavigationActive()) return;
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
|
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
|
||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
@@ -1256,16 +1397,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.prev(true);
|
this.prev(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
this.enableCameraNavigation.set(false);
|
this.settingsService.cameraNavigationActive.set(false);
|
||||||
alert('Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.');
|
const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto';
|
||||||
|
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopCameraNavigation() {
|
stopCameraNavigation() {
|
||||||
this.enableCameraNavigation.set(false);
|
this.settingsService.cameraNavigationActive.set(false);
|
||||||
this.faceDetector.stop();
|
this.faceDetector.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,7 +1415,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.exitFullscreen();
|
this.exitFullscreen();
|
||||||
this.stopAutoscroll();
|
this.stopAutoscroll();
|
||||||
this.logPreviousSongTime();
|
this.logPreviousSongTime();
|
||||||
this.stopCameraNavigation();
|
if (this.settingsService.cameraNavigationActive()) {
|
||||||
|
this.faceDetector.pause();
|
||||||
|
} else {
|
||||||
|
this.faceDetector.stop();
|
||||||
|
}
|
||||||
this.channel.close();
|
this.channel.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatSeconds(seconds: number | undefined | null): string {
|
||||||
|
if (seconds === undefined || seconds === null || isNaN(seconds)) {
|
||||||
|
return '0:00';
|
||||||
|
}
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<ion-title class="outfit-font">Gestione Playlist</ion-title>
|
<ion-title class="outfit-font">Gestione Playlist</ion-title>
|
||||||
<ion-buttons slot="end">
|
<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-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
|
||||||
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -14,8 +17,12 @@
|
|||||||
|
|
||||||
<ion-content class="bg-gradient">
|
<ion-content class="bg-gradient">
|
||||||
<div class="ion-padding">
|
<div class="ion-padding">
|
||||||
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0">
|
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<p>Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
|
<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>
|
||||||
|
|
||||||
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
|
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
|
||||||
|
|||||||
@@ -51,6 +51,42 @@ export class PlaylistPage {
|
|||||||
public qrCodeImage: string | null = null;
|
public qrCodeImage: string | null = null;
|
||||||
public savedPlaylistName: 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() {
|
constructor() {
|
||||||
// Initial copy to allow local reordering
|
// Initial copy to allow local reordering
|
||||||
this.localSongs = [...this.selectedSongs()];
|
this.localSongs = [...this.selectedSongs()];
|
||||||
@@ -178,7 +214,10 @@ export class PlaylistPage {
|
|||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const file = new File([blob], fileName, { type: 'image/png' });
|
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({
|
await navigator.share({
|
||||||
files: [file],
|
files: [file],
|
||||||
title: 'Playlist CantiCristiani',
|
title: 'Playlist CantiCristiani',
|
||||||
@@ -233,4 +272,42 @@ export class PlaylistPage {
|
|||||||
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
|
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
|
||||||
return index !== -1 ? index + 1 : 0;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
<div class="drag-overlay" *ngIf="isDraggingOver">
|
<div class="drag-overlay" *ngIf="isDraggingOver">
|
||||||
<div class="drag-message">
|
<div class="drag-message">
|
||||||
<ion-icon name="image-outline"></ion-icon>
|
<ion-icon name="document-text-outline"></ion-icon>
|
||||||
<p>Rilascia l'immagine qui per estrarre il testo</p>
|
<p>Rilascia l'immagine o il PDF qui per estrarre il testo</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,20 +31,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="editor-preview-split">
|
||||||
<!-- Left Column: Inputs & Editor -->
|
<!-- Left Column: Inputs & Editor -->
|
||||||
<div class="editor-column">
|
<div class="editor-column" [class.hide-on-mobile]="activeTab === 'preview'">
|
||||||
<ion-list lines="none" class="input-list">
|
<ion-list lines="none" class="input-list">
|
||||||
<ion-item class="custom-input-item">
|
<ion-item class="custom-input-item">
|
||||||
<ion-label position="stacked">Titolo del Canto</ion-label>
|
<ion-label position="stacked">Titolo del Canto</ion-label>
|
||||||
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||||
</ion-item>
|
</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">
|
<div class="category-selectors">
|
||||||
<ion-item class="custom-input-item select-item">
|
<ion-item class="custom-input-item select-item">
|
||||||
<ion-label position="stacked">Momento Liturgico</ion-label>
|
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||||
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
||||||
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
|
<ion-select-option *ngFor="let lit of sortedLiturgico" [value]="lit.id">
|
||||||
{{ lit.tag_name }}
|
{{ lit.tag_name }}
|
||||||
</ion-select-option>
|
</ion-select-option>
|
||||||
</ion-select>
|
</ion-select>
|
||||||
@@ -53,7 +75,7 @@
|
|||||||
<ion-item class="custom-input-item select-item">
|
<ion-item class="custom-input-item select-item">
|
||||||
<ion-label position="stacked">Periodo / Tema</ion-label>
|
<ion-label position="stacked">Periodo / Tema</ion-label>
|
||||||
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
||||||
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
|
<ion-select-option *ngFor="let tem of sortedTematico" [value]="tem.id">
|
||||||
{{ tem.tag_name }}
|
{{ tem.tag_name }}
|
||||||
</ion-select-option>
|
</ion-select-option>
|
||||||
</ion-select>
|
</ion-select>
|
||||||
@@ -63,9 +85,14 @@
|
|||||||
<!-- TOOLBARS -->
|
<!-- TOOLBARS -->
|
||||||
<div class="toolbar-section">
|
<div class="toolbar-section">
|
||||||
<div class="horizontal-toolbar">
|
<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">
|
||||||
{{ tag.label }}
|
<ion-button size="small" fill="outline" (click)="insertText(tag.start)">
|
||||||
</ion-button>
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,37 +126,92 @@
|
|||||||
<!-- EDITOR AREA -->
|
<!-- EDITOR AREA -->
|
||||||
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
||||||
<div class="editor-header">
|
<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>
|
<span>Editor Testo</span>
|
||||||
|
|
||||||
|
<!-- Transpose buttons in left column, above textarea -->
|
||||||
|
<div class="editor-transpose-group" style="display: flex; align-items: center; gap: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px 6px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); margin-left: 12px;">
|
||||||
|
<ion-button fill="clear" size="small" (click)="transposeDown()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
|
||||||
|
<ion-icon slot="icon-only" name="remove-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<span class="val outfit-font" style="font-size: 0.85rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 24px; text-align: center;">T:{{ transposeAmount > 0 ? '+' : '' }}{{ transposeAmount }}</span>
|
||||||
|
<ion-button fill="clear" size="small" (click)="transposeUp()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
|
||||||
|
<ion-icon slot="icon-only" name="add-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Set default key button -->
|
||||||
|
<ion-button *ngIf="transposeAmount !== 0" fill="outline" color="secondary" size="small" (click)="setDefaultTonalita()" style="margin: 0 0 0 8px; font-size: 10px; font-weight: 700; height: 24px; --border-radius: 6px;">
|
||||||
|
Imposta tonalità di default
|
||||||
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
|
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica">
|
||||||
<ion-icon name="undo-outline"></ion-icon>
|
<ion-icon name="arrow-undo-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
<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-icon name="camera-outline"></ion-icon>
|
||||||
</ion-button>
|
</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>
|
</div>
|
||||||
<ion-item class="custom-input-item textarea-item">
|
<div class="editor-textarea-container">
|
||||||
<ion-textarea
|
<!-- Input textarea -->
|
||||||
#contentTextarea
|
<textarea
|
||||||
|
#nativeTextarea
|
||||||
[(ngModel)]="content"
|
[(ngModel)]="content"
|
||||||
placeholder="Scrivi o scansiona..."
|
placeholder="Scrivi o scansiona..."
|
||||||
rows="18"
|
(paste)="onPaste($event)"
|
||||||
class="content-textarea"
|
spellcheck="false"
|
||||||
(paste)="onPaste($event)">
|
autocapitalize="none"
|
||||||
</ion-textarea>
|
autocomplete="off"
|
||||||
</ion-item>
|
autocorrect="off">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ion-item class="custom-input-item">
|
<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)]="author" placeholder="Autore"></ion-input>
|
||||||
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-list>
|
|
||||||
|
|
||||||
|
<!-- 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">
|
<div class="action-buttons">
|
||||||
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
|
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
|
||||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||||
@@ -137,13 +219,14 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Active Preview Pane -->
|
<!-- Right Column: Active Preview Pane -->
|
||||||
<div class="preview-column">
|
<div class="preview-column" [class.hide-on-mobile]="activeTab === 'editor'">
|
||||||
<div class="preview-card" [class.hc]="isHighContrast">
|
<div class="preview-card" [class.hc]="isHighContrast">
|
||||||
<div class="preview-header">
|
<div class="preview-header">
|
||||||
<span class="preview-title">Anteprima Attiva</span>
|
<span class="preview-title">Anteprima Attiva</span>
|
||||||
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn">
|
|
||||||
|
<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>
|
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
|
||||||
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
|
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -154,26 +237,28 @@
|
|||||||
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
|
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-lyrics-container">
|
<div class="preview-lyrics-container">
|
||||||
<div *ngFor="let section of parsedSections"
|
<div class="lyrics-view">
|
||||||
class="preview-section"
|
<div *ngFor="let section of parsedSections"
|
||||||
[class.chorus]="section.type === 'chorus'"
|
class="section"
|
||||||
[class.verse-num]="section.type === 'verse_num'">
|
[class.chorus]="section.type === 'chorus'"
|
||||||
|
[class.verse-num]="section.type === 'verse_num'">
|
||||||
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div>
|
|
||||||
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="preview-section-label preview-verse-num-label">{{ section.verseNumber }}.</div>
|
<div *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="preview-lyric-line">
|
|
||||||
<!-- Chord mode -->
|
<div *ngFor="let line of section.lines" class="lyric-line">
|
||||||
<ng-container *ngIf="showChordsPreview; else textOnly">
|
<!-- Chord mode -->
|
||||||
<span *ngFor="let seg of line.segments; let i = index" class="preview-chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
<ng-container *ngIf="showChordsPreview; else textOnly">
|
||||||
<span *ngIf="seg.chord" class="preview-chord">{{ seg.chord }}</span>
|
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||||
<span class="preview-seg-text">{{ seg.text }}</span>
|
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
</span>
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
</ng-container>
|
</span>
|
||||||
<!-- Text only mode -->
|
</ng-container>
|
||||||
<ng-template #textOnly>
|
<!-- Text only mode -->
|
||||||
{{ line.text }}
|
<ng-template #textOnly>
|
||||||
</ng-template>
|
{{ line.text }}
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,8 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Hidden inputs -->
|
<!-- Hidden inputs -->
|
||||||
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
|
<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>
|
</ion-content>
|
||||||
|
|||||||
@@ -94,11 +94,14 @@ body.high-contrast :host ::ng-deep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Textarea inside high contrast must have white background and black text */
|
/* Textarea inside high contrast must have white background and black text */
|
||||||
.content-textarea {
|
.editor-textarea-container {
|
||||||
--color: #000000 !important;
|
|
||||||
color: #000000 !important;
|
|
||||||
background: #ffffff !important;
|
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 */
|
/* Force background of inputs and items to be white with single dark gray border in high contrast */
|
||||||
@@ -267,30 +270,49 @@ body.high-contrast :host ::ng-deep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-textarea {
|
.editor-textarea-container {
|
||||||
--color: #000000 !important;
|
background: #ffffff;
|
||||||
color: #000000 !important;
|
|
||||||
background: #ffffff !important;
|
textarea {
|
||||||
--background: #ffffff !important;
|
color: #000000;
|
||||||
font-size: 18px !important;
|
caret-color: #000000;
|
||||||
font-weight: 700 !important;
|
}
|
||||||
caret-color: #000000;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep {
|
:host ::ng-deep {
|
||||||
.content-textarea {
|
.editor-textarea-container {
|
||||||
font-family: 'Courier New', Courier, monospace;
|
position: relative;
|
||||||
font-size: 15px;
|
width: 100%;
|
||||||
font-weight: 600;
|
height: 450px;
|
||||||
--color: #ffffff !important;
|
background: #111111;
|
||||||
color: #ffffff !important;
|
border-radius: 0 0 14px 14px;
|
||||||
--padding-start: 16px;
|
overflow: hidden;
|
||||||
--padding-end: 16px;
|
box-sizing: border-box;
|
||||||
--padding-top: 16px;
|
|
||||||
--padding-bottom: 16px;
|
textarea {
|
||||||
min-height: 400px;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||||
|
font-size: 1.05rem !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
caret-color: #64ffda !important;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-selectors {
|
.category-selectors {
|
||||||
@@ -569,10 +591,16 @@ body.high-contrast :host ::ng-deep {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section {
|
.lyrics-view {
|
||||||
margin-bottom: 1.8rem;
|
max-width: 900px;
|
||||||
position: relative;
|
margin-left: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&.chorus {
|
&.chorus {
|
||||||
background: rgba(var(--ion-color-secondary-rgb), 0.03);
|
background: rgba(var(--ion-color-secondary-rgb), 0.03);
|
||||||
border-left: 3px solid var(--ion-color-secondary);
|
border-left: 3px solid var(--ion-color-secondary);
|
||||||
@@ -581,25 +609,30 @@ body.high-contrast :host ::ng-deep {
|
|||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section-label {
|
.section-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.5rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-lyric-line {
|
.lyric-line {
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
transition: all 0.3s ease;
|
||||||
min-height: 1.5em;
|
min-height: 1.5em;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
letter-spacing: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-chord-segment {
|
.chord-segment {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
@@ -609,15 +642,16 @@ body.high-contrast :host ::ng-deep {
|
|||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-chord {
|
.chord {
|
||||||
font-size: 0.78em;
|
font-size: 0.75em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
height: 1.25em;
|
height: 1.2em;
|
||||||
margin-bottom: -0.2em;
|
margin-bottom: -0.2em;
|
||||||
|
padding-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-seg-text {
|
.seg-text {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
&::after {
|
&::after {
|
||||||
content: '\200b';
|
content: '\200b';
|
||||||
@@ -653,6 +687,16 @@ body.high-contrast :host ::ng-deep {
|
|||||||
--color: #000000;
|
--color: #000000;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-transpose-group {
|
||||||
|
border-color: #000000 !important;
|
||||||
|
span {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
ion-button {
|
||||||
|
--color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-body {
|
.preview-body {
|
||||||
@@ -671,23 +715,23 @@ body.high-contrast :host ::ng-deep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-lyrics-container {
|
.preview-lyrics-container {
|
||||||
.preview-section {
|
.section {
|
||||||
&.chorus {
|
&.chorus {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
border-left: 3px solid #000000;
|
border-left: 3px solid #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section-label {
|
.section-label {
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-lyric-line {
|
.lyric-line {
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-chord-segment {
|
.chord-segment {
|
||||||
.preview-chord {
|
.chord {
|
||||||
color: #000000;
|
color: #000000;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -701,3 +745,101 @@ body.high-contrast :host ::ng-deep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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,6 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ProposeCantoPage } from './propose-canto.page';
|
import { ProposeCantoPage } from './propose-canto.page';
|
||||||
import { ToastController, PopoverController, NavController, AngularDelegate } from '@ionic/angular';
|
import { ToastController, PopoverController, NavController, AngularDelegate, AlertController } from '@ionic/angular';
|
||||||
import { CantiService } from '../../services/canti.service';
|
import { CantiService } from '../../services/canti.service';
|
||||||
import { MyCantiService } from '../../services/my-canti.service';
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
import { PlaylistService } from '../../services/playlist.service';
|
import { PlaylistService } from '../../services/playlist.service';
|
||||||
@@ -41,6 +41,7 @@ describe('ProposeCantoPage', () => {
|
|||||||
const navCtrlMock = {};
|
const navCtrlMock = {};
|
||||||
const toastControllerMock = {};
|
const toastControllerMock = {};
|
||||||
const popoverControllerMock = {};
|
const popoverControllerMock = {};
|
||||||
|
const alertControllerMock = {};
|
||||||
const angularDelegateMock = {};
|
const angularDelegateMock = {};
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -55,7 +56,8 @@ describe('ProposeCantoPage', () => {
|
|||||||
{ provide: NavController, useValue: navCtrlMock },
|
{ provide: NavController, useValue: navCtrlMock },
|
||||||
{ provide: ToastController, useValue: toastControllerMock },
|
{ provide: ToastController, useValue: toastControllerMock },
|
||||||
{ provide: PopoverController, useValue: popoverControllerMock },
|
{ provide: PopoverController, useValue: popoverControllerMock },
|
||||||
{ provide: AngularDelegate, useValue: angularDelegateMock }
|
{ provide: AngularDelegate, useValue: angularDelegateMock },
|
||||||
|
{ provide: AlertController, useValue: alertControllerMock }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,4 +126,26 @@ describe('ProposeCantoPage', () => {
|
|||||||
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
|
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
@@ -53,6 +53,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
<ion-item class="transparent-item" lines="none">
|
<ion-item class="transparent-item" lines="none">
|
||||||
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
|
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
|
||||||
@@ -104,6 +118,14 @@
|
|||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
|
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
|
||||||
</ion-item>
|
</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-item class="transparent-item" lines="none">
|
||||||
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
|
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
|
||||||
<ion-label class="outfit-font">
|
<ion-label class="outfit-font">
|
||||||
@@ -136,6 +158,14 @@
|
|||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-toggle slot="end" [checked]="settingsService.landscapeProjectionEnabled()" (ionChange)="settingsService.toggleLandscapeProjectionEnabled()" color="secondary"></ion-toggle>
|
<ion-toggle slot="end" [checked]="settingsService.landscapeProjectionEnabled()" (ionChange)="settingsService.toggleLandscapeProjectionEnabled()" color="secondary"></ion-toggle>
|
||||||
</ion-item>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Comunità -->
|
<!-- Comunità -->
|
||||||
@@ -175,7 +205,7 @@
|
|||||||
<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;">
|
<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"
|
<input type="text"
|
||||||
#comunitaCodeInput
|
#comunitaCodeInput
|
||||||
placeholder="Es: 123456"
|
placeholder="il codice di 6 cifre"
|
||||||
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
|
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
|
||||||
class="outfit-font"
|
class="outfit-font"
|
||||||
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
|
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
|
||||||
@@ -251,6 +281,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Importa Playlist -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
|
<ion-item class="transparent-item" lines="none" button (click)="importPlaylistViaQr()">
|
||||||
|
<ion-icon name="qr-code-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Importa Playlist via QR</h2>
|
||||||
|
<p class="settings-item-subtitle">Inquadra il QR di una playlist condivisa per importarla</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-icon name="chevron-forward-outline" slot="end" color="medium" style="font-size: 1rem;"></ion-icon>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Invio Dati Statistici -->
|
<!-- Invio Dati Statistici -->
|
||||||
<div class="settings-group glass ion-margin-bottom">
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
<ion-item class="transparent-item" lines="none">
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
|||||||
@@ -77,6 +77,19 @@ export class SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async manualRestore() {
|
||||||
const alert = await this.alertCtrl.create({
|
const alert = await this.alertCtrl.create({
|
||||||
header: 'Ripristina con Codice',
|
header: 'Ripristina con Codice',
|
||||||
@@ -159,7 +172,9 @@ export class SettingsPage {
|
|||||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||||
autore: item.autore || '',
|
autore: item.autore || '',
|
||||||
link_youtube: item.link_youtube || '',
|
link_youtube: item.link_youtube || '',
|
||||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
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) {
|
if (customSongs.length > 0) {
|
||||||
@@ -347,7 +362,9 @@ export class SettingsPage {
|
|||||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||||
autore: item.autore || '',
|
autore: item.autore || '',
|
||||||
link_youtube: item.link_youtube || '',
|
link_youtube: item.link_youtube || '',
|
||||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
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);
|
this.myCantiService.myCanti.set(customSongs);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, signal, inject, effect } from '@angular/core';
|
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 { Storage } from '@ionic/storage-angular';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
id_canto?: number;
|
id_canto?: number;
|
||||||
@@ -127,7 +128,18 @@ export class CantiLettureService {
|
|||||||
|
|
||||||
const savedDate = localStorage.getItem('selected-mass-date');
|
const savedDate = localStorage.getItem('selected-mass-date');
|
||||||
if (savedDate) {
|
if (savedDate) {
|
||||||
this.selectedMassDate.set(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
|
// Fetch fresh data
|
||||||
@@ -137,24 +149,45 @@ export class CantiLettureService {
|
|||||||
async fetchData() {
|
async fetchData() {
|
||||||
try {
|
try {
|
||||||
let fetchedData: CantiLettureData | null = null;
|
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 isProduction = window.location.hostname.includes('canticristiani.it');
|
||||||
const primaryUrl = isProduction
|
const primaryUrl = isProduction
|
||||||
? `${window.location.origin}${this.SECURE_JSON_URL}`
|
? `${window.location.origin}${this.SECURE_JSON_URL}`
|
||||||
: 'https://www.canticristiani.it/api/cantiletture.json';
|
: '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 {
|
try {
|
||||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
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) {
|
} 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 {
|
try {
|
||||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`));
|
console.log('Fetching mass data from direct API:', this.JSON_URL);
|
||||||
} catch (fallbackErr) {
|
const authUser = environment.apiAuthUser || 'canti';
|
||||||
console.error('Fallback fetch also failed:', fallbackErr);
|
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,24 +227,29 @@ export class CantiLettureService {
|
|||||||
|
|
||||||
this.availableMasses.set(massesList);
|
this.availableMasses.set(massesList);
|
||||||
|
|
||||||
// Always select today's mass if available on load, else find closest future date
|
|
||||||
if (massesList.length > 0) {
|
if (massesList.length > 0) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(now.getDate()).padStart(2, '0');
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
const todayStr = `${year}-${month}-${day}`;
|
const todayStr = `${year}-${month}-${day}`;
|
||||||
const match = massesList.find(m => m.date === todayStr);
|
|
||||||
if (match) {
|
const currentSelected = this.selectedMassDate();
|
||||||
this.selectedMassDate.set(match.date);
|
const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr);
|
||||||
} else {
|
|
||||||
// If today is not present, find the first date that is in the future
|
if (!isCurrentValid) {
|
||||||
const futureMatch = massesList.find(m => m.date >= todayStr);
|
const match = massesList.find(m => m.date === todayStr);
|
||||||
if (futureMatch) {
|
if (match) {
|
||||||
this.selectedMassDate.set(futureMatch.date);
|
this.selectedMassDate.set(match.date);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise fall back to the last available mass date (closest to today)
|
// If today is not present, find the first date that is in the future
|
||||||
this.selectedMassDate.set(massesList[massesList.length - 1].date);
|
const futureMatch = massesList.find(m => m.date >= todayStr);
|
||||||
|
if (futureMatch) {
|
||||||
|
this.selectedMassDate.set(futureMatch.date);
|
||||||
|
} else {
|
||||||
|
// Otherwise fall back to the last available mass date (closest to today)
|
||||||
|
this.selectedMassDate.set(massesList[massesList.length - 1].date);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface Canto {
|
|||||||
data_update?: string;
|
data_update?: string;
|
||||||
nonValidato?: boolean;
|
nonValidato?: boolean;
|
||||||
isPersonal?: boolean;
|
isPersonal?: boolean;
|
||||||
|
durata?: string;
|
||||||
|
bpm?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Indice {
|
export interface Indice {
|
||||||
@@ -48,7 +50,12 @@ export class CantiService {
|
|||||||
public progress = signal<number>(0);
|
public progress = signal<number>(0);
|
||||||
public firstLoadCompleted = signal<boolean>(false);
|
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() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
@@ -92,7 +99,7 @@ export class CantiService {
|
|||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.progress.set(0);
|
this.progress.set(0);
|
||||||
|
|
||||||
this.http.get(`${this.API_URL}?t=${Date.now()}`, {
|
this.http.get(`${this.getApiUrl()}?t=${Date.now()}`, {
|
||||||
reportProgress: true,
|
reportProgress: true,
|
||||||
observe: 'events'
|
observe: 'events'
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
|
|||||||
@@ -187,7 +187,9 @@ export class ComunitaService {
|
|||||||
id_momenti: [],
|
id_momenti: [],
|
||||||
data_update: cp.data_update || '',
|
data_update: cp.data_update || '',
|
||||||
nonValidato: Number(cp.stato) === 10,
|
nonValidato: Number(cp.stato) === 10,
|
||||||
isPersonal: true
|
isPersonal: true,
|
||||||
|
durata: cp.durata || '',
|
||||||
|
bpm: cp.bpm !== undefined && cp.bpm !== null ? Number(cp.bpm) : undefined
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.comunitaCode.set(trimmedCode);
|
this.comunitaCode.set(trimmedCode);
|
||||||
|
|||||||
@@ -9,111 +9,106 @@ export class FaceDetectorService {
|
|||||||
public isTilted = signal<boolean>(false);
|
public isTilted = signal<boolean>(false);
|
||||||
|
|
||||||
private stream: MediaStream | null = null;
|
private stream: MediaStream | null = null;
|
||||||
private camera: any = null;
|
private animFrameId: number | null = null;
|
||||||
private faceMesh: any = null;
|
private faceMesh: any = null;
|
||||||
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
|
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
|
||||||
|
|
||||||
// Gesture state machine
|
// Gesture state machine
|
||||||
private tiltStartTime: number = 0;
|
private tiltStartTime: number = 0;
|
||||||
private inCooldown: boolean = false;
|
private inCooldown: boolean = false;
|
||||||
private readonly TILT_THRESHOLD = 15; // Degrees to trigger next/prev page
|
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 TILT_HOLD_MS = 300; // How long to hold the tilt
|
||||||
private readonly RETURN_THRESHOLD = 6; // Degrees to reset cooldown
|
private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown
|
||||||
|
private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads MediaPipe scripts dynamically if not already loaded.
|
* Loads MediaPipe script dynamically if not already loaded.
|
||||||
*/
|
*/
|
||||||
private loadScripts(): Promise<void> {
|
private loadScripts(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if ((window as any).FaceMesh && (window as any).Camera) {
|
if ((window as any).FaceMesh) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraScript = document.createElement('script');
|
|
||||||
cameraScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js';
|
|
||||||
cameraScript.crossOrigin = 'anonymous';
|
|
||||||
|
|
||||||
const faceMeshScript = document.createElement('script');
|
const faceMeshScript = document.createElement('script');
|
||||||
faceMeshScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js';
|
faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
|
||||||
faceMeshScript.crossOrigin = 'anonymous';
|
faceMeshScript.onload = () => resolve();
|
||||||
|
|
||||||
cameraScript.onload = () => {
|
|
||||||
document.head.appendChild(faceMeshScript);
|
|
||||||
};
|
|
||||||
|
|
||||||
faceMeshScript.onload = () => {
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
cameraScript.onerror = (err) => reject(err);
|
|
||||||
faceMeshScript.onerror = (err) => reject(err);
|
faceMeshScript.onerror = (err) => reject(err);
|
||||||
|
|
||||||
document.head.appendChild(cameraScript);
|
document.head.appendChild(faceMeshScript);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts camera capture and face mesh tracking.
|
* 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> {
|
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> {
|
||||||
if (this.isCameraActive()) return;
|
|
||||||
this.onTiltCallback = onTilt;
|
this.onTiltCallback = onTilt;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.loadScripts();
|
await this.loadScripts();
|
||||||
|
|
||||||
// Request camera permissions and stream
|
const isStreamActive = this.stream &&
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
this.stream.active &&
|
||||||
video: {
|
this.stream.getVideoTracks().some(track => track.readyState === 'live');
|
||||||
width: { ideal: 320 },
|
|
||||||
height: { ideal: 240 },
|
if (!isStreamActive) {
|
||||||
facingMode: 'user'
|
// Request camera permissions and stream ONCE
|
||||||
},
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false
|
video: {
|
||||||
});
|
width: { ideal: 320 },
|
||||||
|
height: { ideal: 240 },
|
||||||
|
facingMode: 'user'
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
videoElement.srcObject = this.stream;
|
videoElement.srcObject = this.stream;
|
||||||
videoElement.setAttribute('playsinline', 'true');
|
videoElement.setAttribute('playsinline', 'true');
|
||||||
|
(videoElement as any).playsInline = true;
|
||||||
videoElement.muted = true;
|
videoElement.muted = true;
|
||||||
videoElement.play();
|
|
||||||
|
try {
|
||||||
const FaceMeshLib = (window as any).FaceMesh;
|
await videoElement.play();
|
||||||
const CameraLib = (window as any).Camera;
|
} catch (playErr) {
|
||||||
|
console.warn('[FaceDetector] Video play warning:', playErr);
|
||||||
if (!FaceMeshLib || !CameraLib) {
|
|
||||||
throw new Error('MediaPipe libraries failed to initialize.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.faceMesh = new FaceMeshLib({
|
if (!this.faceMesh) {
|
||||||
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
|
const FaceMeshLib = (window as any).FaceMesh;
|
||||||
});
|
if (!FaceMeshLib) {
|
||||||
|
throw new Error('MediaPipe FaceMesh library failed to initialize.');
|
||||||
|
}
|
||||||
|
|
||||||
this.faceMesh.setOptions({
|
this.faceMesh = new FaceMeshLib({
|
||||||
maxNumFaces: 1,
|
locateFile: (file: string) => `assets/mediapipe/${file}`
|
||||||
refineLandmarks: false,
|
});
|
||||||
minDetectionConfidence: 0.6,
|
|
||||||
minTrackingConfidence: 0.6
|
|
||||||
});
|
|
||||||
|
|
||||||
this.faceMesh.onResults((results: any) => {
|
this.faceMesh.setOptions({
|
||||||
this.processLandmarks(results);
|
maxNumFaces: 1,
|
||||||
});
|
refineLandmarks: false,
|
||||||
|
minDetectionConfidence: 0.6,
|
||||||
|
minTrackingConfidence: 0.6
|
||||||
|
});
|
||||||
|
|
||||||
this.camera = new CameraLib(videoElement, {
|
this.faceMesh.onResults((results: any) => {
|
||||||
onFrame: async () => {
|
this.processLandmarks(results);
|
||||||
if (this.isCameraActive() && this.faceMesh) {
|
});
|
||||||
await this.faceMesh.send({ image: videoElement });
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
width: 320,
|
|
||||||
height: 240
|
|
||||||
});
|
|
||||||
|
|
||||||
this.isCameraActive.set(true);
|
this.isCameraActive.set(true);
|
||||||
await this.camera.start();
|
|
||||||
|
// Start custom frame processing loop
|
||||||
|
this.stopFrameLoop();
|
||||||
|
this.startFrameLoop(videoElement);
|
||||||
|
|
||||||
console.log('[FaceDetector] Face tracking started successfully.');
|
console.log('[FaceDetector] Face tracking started successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[FaceDetector] Failed to start face tracking:', err);
|
console.error('[FaceDetector] Failed to start face tracking:', err);
|
||||||
@@ -122,6 +117,38 @@ export class FaceDetectorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* Processes landmarks to calculate head tilt angle.
|
||||||
*/
|
*/
|
||||||
@@ -155,7 +182,7 @@ export class FaceDetectorService {
|
|||||||
if (absAngle > this.TILT_THRESHOLD) {
|
if (absAngle > this.TILT_THRESHOLD) {
|
||||||
this.isTilted.set(true);
|
this.isTilted.set(true);
|
||||||
|
|
||||||
if (!this.inCooldown) {
|
if (!this.inCooldown && (Date.now() - this.lastTriggerTime > this.TRIGGER_COOLDOWN_MS)) {
|
||||||
if (this.tiltStartTime === 0) {
|
if (this.tiltStartTime === 0) {
|
||||||
this.tiltStartTime = Date.now();
|
this.tiltStartTime = Date.now();
|
||||||
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
|
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
|
||||||
@@ -167,6 +194,7 @@ export class FaceDetectorService {
|
|||||||
}
|
}
|
||||||
this.inCooldown = true;
|
this.inCooldown = true;
|
||||||
this.tiltStartTime = 0;
|
this.tiltStartTime = 0;
|
||||||
|
this.lastTriggerTime = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -181,21 +209,23 @@ export class FaceDetectorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops camera capture and releases face mesh resources.
|
* 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() {
|
stop() {
|
||||||
this.isCameraActive.set(false);
|
this.pause();
|
||||||
this.currentTiltAngle.set(0);
|
this.currentTiltAngle.set(0);
|
||||||
this.isTilted.set(false);
|
this.isTilted.set(false);
|
||||||
this.inCooldown = false;
|
this.inCooldown = false;
|
||||||
this.tiltStartTime = 0;
|
this.tiltStartTime = 0;
|
||||||
|
this.lastTriggerTime = 0;
|
||||||
if (this.camera) {
|
|
||||||
try {
|
|
||||||
this.camera.stop();
|
|
||||||
} catch (e) {}
|
|
||||||
this.camera = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.stream) {
|
if (this.stream) {
|
||||||
this.stream.getTracks().forEach(track => track.stop());
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
@@ -210,6 +240,6 @@ export class FaceDetectorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.onTiltCallback = null;
|
this.onTiltCallback = null;
|
||||||
console.log('[FaceDetector] Face tracking stopped.');
|
console.log('[FaceDetector] Face tracking fully stopped.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,4 +101,48 @@ Rallegri*amoci,*
|
|||||||
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
|
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
|
||||||
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
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(['|', '|', '|', '|']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,11 +116,12 @@ export class LyricsParserService {
|
|||||||
currentLines = [];
|
currentLines = [];
|
||||||
currentRawLines = [];
|
currentRawLines = [];
|
||||||
currentAction = null;
|
currentAction = null;
|
||||||
|
currentType = 'verse';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip structural tags (already handled above)
|
// Skip structural tags (already handled above)
|
||||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
if (trimmed.startsWith('{') && trimmed.endsWith('}') && !trimmed.startsWith('{c:') && !trimmed.startsWith('{comment:')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +149,11 @@ export class LyricsParserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
currentRawLines.push(line);
|
||||||
|
|
||||||
// Parse line
|
// Parse line
|
||||||
@@ -188,9 +194,13 @@ export class LyricsParserService {
|
|||||||
* sei Re Gesù[SOL] → text "sei Re Gesù" then chord "SOL" with empty text
|
* sei Re Gesù[SOL] → text "sei Re Gesù" then chord "SOL" with empty text
|
||||||
*/
|
*/
|
||||||
parseChordLine(line: string): ParsedLine {
|
parseChordLine(line: string): ParsedLine {
|
||||||
|
// Treat vertical bars (|) as chords instead of text
|
||||||
|
let prepared = line.replace(/\[\|\]/g, '|');
|
||||||
|
prepared = prepared.replace(/\|/g, '[|]');
|
||||||
|
|
||||||
const segments: ChordSegment[] = [];
|
const segments: ChordSegment[] = [];
|
||||||
// Clean up non-breaking spaces
|
// 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
|
// Regex to match [CHORD] tags and text between them
|
||||||
const chordRegex = /\[([^\]]+)\]/g;
|
const chordRegex = /\[([^\]]+)\]/g;
|
||||||
@@ -245,6 +255,12 @@ export class LyricsParserService {
|
|||||||
*/
|
*/
|
||||||
transposeChord(chord: string, semitones: number): string {
|
transposeChord(chord: string, semitones: number): string {
|
||||||
if (!chord) 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)
|
// Handle slash chords (e.g., DO/SOL)
|
||||||
if (chord.includes('/')) {
|
if (chord.includes('/')) {
|
||||||
@@ -329,6 +345,11 @@ export class LyricsParserService {
|
|||||||
isContiguousNext(segments: ChordSegment[], index: number): boolean {
|
isContiguousNext(segments: ChordSegment[], index: number): boolean {
|
||||||
if (!segments || index >= segments.length - 1) return false;
|
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
|
// Find the next segment with non-empty text
|
||||||
let nextWithText: ChordSegment | null = null;
|
let nextWithText: ChordSegment | null = null;
|
||||||
for (let i = index + 1; i < segments.length; i++) {
|
for (let i = index + 1; i < segments.length; i++) {
|
||||||
@@ -351,5 +372,42 @@ export class LyricsParserService {
|
|||||||
|
|
||||||
return endsWithNonSpace && startsWithNonSpace;
|
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 { CantiService } from './canti.service';
|
||||||
import { MyCantiService } from './my-canti.service';
|
import { MyCantiService } from './my-canti.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
|
import { PlaylistService } from './playlist.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -10,6 +11,7 @@ export class MediaSessionService {
|
|||||||
private cantiService = inject(CantiService);
|
private cantiService = inject(CantiService);
|
||||||
private myCantiService = inject(MyCantiService);
|
private myCantiService = inject(MyCantiService);
|
||||||
private comunitaService = inject(ComunitaService);
|
private comunitaService = inject(ComunitaService);
|
||||||
|
private playlistService = inject(PlaylistService);
|
||||||
|
|
||||||
public updateMetadata(cantoId: string) {
|
public updateMetadata(cantoId: string) {
|
||||||
if (!('mediaSession' in navigator)) return;
|
if (!('mediaSession' in navigator)) return;
|
||||||
@@ -21,6 +23,12 @@ export class MediaSessionService {
|
|||||||
if (!canto) {
|
if (!canto) {
|
||||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
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;
|
if (!canto) return;
|
||||||
|
|
||||||
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
|
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
|
||||||
|
|||||||
@@ -17,17 +17,21 @@ export class MyCantiService {
|
|||||||
|
|
||||||
private _storage: Storage | null = null;
|
private _storage: Storage | null = null;
|
||||||
public myCanti = signal<Canto[]>([]);
|
public myCanti = signal<Canto[]>([]);
|
||||||
|
private initPromise!: Promise<void>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.init();
|
this.initPromise = this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init(): Promise<void> {
|
||||||
this._storage = this.cantiService.getStorage();
|
this._storage = this.cantiService.getStorage();
|
||||||
if (!this._storage) {
|
if (!this._storage) {
|
||||||
// If CantiService hasn't initialized storage yet, wait a bit
|
return new Promise<void>((resolve) => {
|
||||||
setTimeout(() => this.init(), 500);
|
setTimeout(async () => {
|
||||||
return;
|
await this.init();
|
||||||
|
resolve();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const saved = await this._storage.get('my-canti');
|
const saved = await this._storage.get('my-canti');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -36,6 +40,7 @@ export class MyCantiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
|
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
|
||||||
|
await this.initPromise;
|
||||||
const current = this.myCanti();
|
const current = this.myCanti();
|
||||||
let updated: Canto[];
|
let updated: Canto[];
|
||||||
let targetCanto: Canto;
|
let targetCanto: Canto;
|
||||||
@@ -51,7 +56,9 @@ export class MyCantiService {
|
|||||||
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
|
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
|
||||||
autore: canto.autore !== undefined ? canto.autore : c.autore,
|
autore: canto.autore !== undefined ? canto.autore : c.autore,
|
||||||
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
|
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
|
||||||
id_momenti: canto.id_momenti || c.id_momenti
|
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 targetCanto;
|
||||||
}
|
}
|
||||||
@@ -69,7 +76,9 @@ export class MyCantiService {
|
|||||||
accordi: canto.accordi,
|
accordi: canto.accordi,
|
||||||
autore: canto.autore,
|
autore: canto.autore,
|
||||||
link_youtube: canto.link_youtube,
|
link_youtube: canto.link_youtube,
|
||||||
id_momenti: canto.id_momenti || []
|
id_momenti: canto.id_momenti || [],
|
||||||
|
durata: canto.durata,
|
||||||
|
bpm: canto.bpm
|
||||||
};
|
};
|
||||||
updated.push(targetCanto);
|
updated.push(targetCanto);
|
||||||
}
|
}
|
||||||
@@ -83,7 +92,9 @@ export class MyCantiService {
|
|||||||
accordi: canto.accordi,
|
accordi: canto.accordi,
|
||||||
autore: canto.autore,
|
autore: canto.autore,
|
||||||
link_youtube: canto.link_youtube,
|
link_youtube: canto.link_youtube,
|
||||||
id_momenti: canto.id_momenti || []
|
id_momenti: canto.id_momenti || [],
|
||||||
|
durata: canto.durata,
|
||||||
|
bpm: canto.bpm
|
||||||
};
|
};
|
||||||
updated = [...current, targetCanto];
|
updated = [...current, targetCanto];
|
||||||
}
|
}
|
||||||
@@ -108,6 +119,7 @@ export class MyCantiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteCanto(id: string) {
|
async deleteCanto(id: string) {
|
||||||
|
await this.initPromise;
|
||||||
const updated = this.myCanti().filter(c => c.id !== id);
|
const updated = this.myCanti().filter(c => c.id !== id);
|
||||||
this.myCanti.set(updated);
|
this.myCanti.set(updated);
|
||||||
await this._storage?.set('my-canti', updated);
|
await this._storage?.set('my-canti', updated);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { Storage } from '@ionic/storage-angular';
|
|||||||
import { Canto, CantiService } from './canti.service';
|
import { Canto, CantiService } from './canti.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { ToastController, AlertController } from '@ionic/angular';
|
import { ToastController, AlertController, ModalController } from '@ionic/angular';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
import { SharePlaylistQrModalComponent } from '../components/share-playlist-qr-modal/share-playlist-qr-modal.component';
|
||||||
import { MyCantiService } from './my-canti.service';
|
import { MyCantiService } from './my-canti.service';
|
||||||
|
import { LyricsParserService } from './lyrics-parser.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -13,9 +15,11 @@ import { MyCantiService } from './my-canti.service';
|
|||||||
export class PlaylistService {
|
export class PlaylistService {
|
||||||
private storage = inject(Storage);
|
private storage = inject(Storage);
|
||||||
private cantiService = inject(CantiService);
|
private cantiService = inject(CantiService);
|
||||||
|
private lyricsParser = inject(LyricsParserService);
|
||||||
private comunitaService = inject(ComunitaService);
|
private comunitaService = inject(ComunitaService);
|
||||||
private toastCtrl = inject(ToastController);
|
private toastCtrl = inject(ToastController);
|
||||||
private alertCtrl = inject(AlertController);
|
private alertCtrl = inject(AlertController);
|
||||||
|
private modalCtrl = inject(ModalController);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private injector = inject(Injector);
|
private injector = inject(Injector);
|
||||||
private myCantiService!: MyCantiService;
|
private myCantiService!: MyCantiService;
|
||||||
@@ -29,10 +33,12 @@ export class PlaylistService {
|
|||||||
public activeListIds = signal<string[]>([]);
|
public activeListIds = signal<string[]>([]);
|
||||||
public activeListName = signal<string | null>(null);
|
public activeListName = signal<string | null>(null);
|
||||||
public activePlaylistId = signal<string | null>(null);
|
public activePlaylistId = signal<string | null>(null);
|
||||||
|
public filteredListIds = signal<string[]>([]);
|
||||||
|
|
||||||
public remotePlaylist = signal<any | null>(null);
|
public remotePlaylist = signal<any | null>(null);
|
||||||
public remoteCustomSongs = signal<any[]>([]);
|
public remoteCustomSongs = signal<any[]>([]);
|
||||||
public remoteShareCanti = signal<Canto[]>([]);
|
public remoteShareCanti = signal<Canto[]>([]);
|
||||||
|
public hasRemotePlaylistUpdate = signal<boolean>(false);
|
||||||
|
|
||||||
private _storage: Storage | null = null;
|
private _storage: Storage | null = null;
|
||||||
private initPromise!: Promise<void>;
|
private initPromise!: Promise<void>;
|
||||||
@@ -43,10 +49,7 @@ export class PlaylistService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.comunitaService.comunitaScalette().map(s => ({
|
return this.comunitaService.comunitaScalette().map(s => ({
|
||||||
id: s.id,
|
...s,
|
||||||
name: s.name,
|
|
||||||
ids: s.ids,
|
|
||||||
createdAt: s.date,
|
|
||||||
isComunita: true
|
isComunita: true
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -83,21 +86,23 @@ export class PlaylistService {
|
|||||||
|
|
||||||
getPlaylistsStorageKey(): string {
|
getPlaylistsStorageKey(): string {
|
||||||
const code = this.comunitaService.comunitaCode();
|
const code = this.comunitaService.comunitaCode();
|
||||||
const isCommunityActive = this.comunitaService.isFilterActive();
|
const active = this.comunitaService.isFilterActive();
|
||||||
if (code && isCommunityActive) {
|
if (code && active) {
|
||||||
return `playlists_comunita_${code}`;
|
return `playlists_${code}`;
|
||||||
}
|
}
|
||||||
return 'playlists';
|
return 'playlists';
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init(): Promise<void> {
|
||||||
const storage = await this.storage.create();
|
this._storage = this.cantiService.getStorage();
|
||||||
this._storage = storage;
|
if (!this._storage) {
|
||||||
await this.loadPlaylistsForCurrentContext();
|
return new Promise<void>((resolve) => {
|
||||||
}
|
setTimeout(async () => {
|
||||||
|
await this.init();
|
||||||
async loadPlaylistsForCurrentContext() {
|
resolve();
|
||||||
if (!this._storage) return;
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
const key = this.getPlaylistsStorageKey();
|
const key = this.getPlaylistsStorageKey();
|
||||||
const saved = await this._storage.get(key);
|
const saved = await this._storage.get(key);
|
||||||
this.playlists.set(saved || []);
|
this.playlists.set(saved || []);
|
||||||
@@ -126,6 +131,73 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadPlaylistsForCurrentContext() {
|
||||||
|
await this.initPromise;
|
||||||
|
const key = this.getPlaylistsStorageKey();
|
||||||
|
const saved = await this._storage?.get(key);
|
||||||
|
this.playlists.set(saved || []);
|
||||||
|
|
||||||
|
const lastKey = `lastPlaylist_${key}`;
|
||||||
|
const last = await this._storage?.get(lastKey);
|
||||||
|
this.lastPlaylist.set(last || null);
|
||||||
|
|
||||||
|
// Carica la playlist remota persistita e i relativi canti personalizzati
|
||||||
|
const remotePl = await this._storage?.get('remote_playlist');
|
||||||
|
if (remotePl) {
|
||||||
|
this.remotePlaylist.set(remotePl);
|
||||||
|
}
|
||||||
|
const remoteSongs = await this._storage?.get('remote_custom_songs');
|
||||||
|
if (remoteSongs) {
|
||||||
|
this.remoteCustomSongs.set(remoteSongs);
|
||||||
|
}
|
||||||
|
const remoteShareSongs = await this._storage?.get('remote_share_canti');
|
||||||
|
if (remoteShareSongs) {
|
||||||
|
this.remoteShareCanti.set(remoteShareSongs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna in background la playlist remota per sincronizzare eventuali modifiche
|
||||||
|
if (remotePl) {
|
||||||
|
this.refreshRemotePlaylist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkForRemotePlaylistUpdates() {
|
||||||
|
const remotePl = this.remotePlaylist();
|
||||||
|
if (!remotePl) {
|
||||||
|
this.hasRemotePlaylistUpdate.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = remotePl.id.split('_');
|
||||||
|
if (parts.length < 3) return;
|
||||||
|
const uid = parts[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const remoteJson = await response.json();
|
||||||
|
if (Array.isArray(remoteJson)) {
|
||||||
|
const playlists = remoteJson.filter((item: any) => item.momenti && item.momenti.includes('Playlist'));
|
||||||
|
const updatedPl = playlists.find((p: any) => `remote_${uid}_${p.id_canti}` === remotePl.id);
|
||||||
|
if (updatedPl) {
|
||||||
|
const serverIds = updatedPl.testo ? updatedPl.testo.split(',') : [];
|
||||||
|
const localIds = remotePl.ids || [];
|
||||||
|
|
||||||
|
const idsMatch = serverIds.length === localIds.length && serverIds.every((id: string, idx: number) => id === localIds[idx]);
|
||||||
|
const nameMatch = `[Remote] ${updatedPl.titolo}` === remotePl.name;
|
||||||
|
|
||||||
|
if (!idsMatch || !nameMatch) {
|
||||||
|
this.hasRemotePlaylistUpdate.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.hasRemotePlaylistUpdate.set(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to check for remote playlist updates:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshRemotePlaylist() {
|
async refreshRemotePlaylist() {
|
||||||
const remotePl = this.remotePlaylist();
|
const remotePl = this.remotePlaylist();
|
||||||
if (!remotePl) return;
|
if (!remotePl) return;
|
||||||
@@ -149,7 +221,9 @@ export class PlaylistService {
|
|||||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||||
autore: item.autore || '',
|
autore: item.autore || '',
|
||||||
link_youtube: item.link_youtube || '',
|
link_youtube: item.link_youtube || '',
|
||||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
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
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const playlists = remoteJson
|
const playlists = remoteJson
|
||||||
@@ -177,6 +251,12 @@ export class PlaylistService {
|
|||||||
this.remoteCustomSongs.set(customSongs);
|
this.remoteCustomSongs.set(customSongs);
|
||||||
await this._storage?.set('remote_playlist', updatedPl);
|
await this._storage?.set('remote_playlist', updatedPl);
|
||||||
await this._storage?.set('remote_custom_songs', customSongs);
|
await this._storage?.set('remote_custom_songs', customSongs);
|
||||||
|
this.hasRemotePlaylistUpdate.set(false);
|
||||||
|
|
||||||
|
if (this.activePlaylistId() === remotePl.id) {
|
||||||
|
this.activeListIds.set(updatedPl.ids);
|
||||||
|
this.activeListName.set(updatedPl.name);
|
||||||
|
}
|
||||||
console.log('[Remote-Sync] Remote playlist and custom songs updated successfully.');
|
console.log('[Remote-Sync] Remote playlist and custom songs updated successfully.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +337,52 @@ export class PlaylistService {
|
|||||||
this.syncLocalDataToServer();
|
this.syncLocalDataToServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clonePlaylist(name: string, ids: string[], songSettings?: any) {
|
||||||
|
await this.initPromise;
|
||||||
|
const isCommunityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive();
|
||||||
|
const key = 'playlists';
|
||||||
|
const lastKey = `lastPlaylist_${key}`;
|
||||||
|
|
||||||
|
// Create the cloned playlist
|
||||||
|
const newPlaylist = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name,
|
||||||
|
ids,
|
||||||
|
songSettings: songSettings || {},
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
let personalPlaylists: any[] = [];
|
||||||
|
if (isCommunityActive) {
|
||||||
|
// If community is active, read the personal playlists from storage
|
||||||
|
const saved = await this._storage?.get(key);
|
||||||
|
personalPlaylists = saved || [];
|
||||||
|
} else {
|
||||||
|
// If personal context is active, we can use the current signal
|
||||||
|
personalPlaylists = this.playlists();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [newPlaylist, ...personalPlaylists];
|
||||||
|
|
||||||
|
if (isCommunityActive) {
|
||||||
|
// Save directly to storage for personal context
|
||||||
|
await this._storage?.set(key, updated);
|
||||||
|
await this._storage?.set(`lastPlaylist_playlists`, newPlaylist);
|
||||||
|
} else {
|
||||||
|
// Update signal and save to storage
|
||||||
|
this.playlists.set(updated);
|
||||||
|
this.lastPlaylist.set(newPlaylist);
|
||||||
|
this.activeListIds.set(ids);
|
||||||
|
this.activeListName.set(name);
|
||||||
|
this.activePlaylistId.set(newPlaylist.id);
|
||||||
|
await this._storage?.set(key, updated);
|
||||||
|
await this._storage?.set(lastKey, newPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizza automaticamente in background
|
||||||
|
this.syncLocalDataToServer();
|
||||||
|
}
|
||||||
|
|
||||||
async replaceSongIdInPlaylists(oldId: string, newId: string) {
|
async replaceSongIdInPlaylists(oldId: string, newId: string) {
|
||||||
await this.initPromise;
|
await this.initPromise;
|
||||||
this.playlists.update(p => p.map(pl => {
|
this.playlists.update(p => p.map(pl => {
|
||||||
@@ -282,15 +408,14 @@ export class PlaylistService {
|
|||||||
this.syncLocalDataToServer();
|
this.syncLocalDataToServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number, zoom?: number) {
|
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number) {
|
||||||
await this.initPromise;
|
await this.initPromise;
|
||||||
this.playlists.update(p => p.map(pl => {
|
this.playlists.update(p => p.map(pl => {
|
||||||
if (pl.id === playlistId) {
|
if (pl.id === playlistId) {
|
||||||
const songSettings = { ...(pl.songSettings || {}) };
|
const songSettings = { ...(pl.songSettings || {}) };
|
||||||
songSettings[songId] = {
|
songSettings[songId] = {
|
||||||
tonalita,
|
tonalita,
|
||||||
speed,
|
speed
|
||||||
zoom: zoom !== undefined ? zoom : songSettings[songId]?.zoom
|
|
||||||
};
|
};
|
||||||
return { ...pl, songSettings };
|
return { ...pl, songSettings };
|
||||||
}
|
}
|
||||||
@@ -368,7 +493,9 @@ export class PlaylistService {
|
|||||||
accordi: c.accordi || '',
|
accordi: c.accordi || '',
|
||||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||||
periodi: [] as string[],
|
periodi: [] as string[],
|
||||||
testo: c.testo || ''
|
testo: c.testo || '',
|
||||||
|
durata: c.durata || '',
|
||||||
|
bpm: c.bpm !== undefined ? c.bpm : null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,7 +509,41 @@ export class PlaylistService {
|
|||||||
accordi: c.accordi || '',
|
accordi: c.accordi || '',
|
||||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||||
periodi: [] as string[],
|
periodi: [] as string[],
|
||||||
testo: c.testo || ''
|
testo: c.testo || '',
|
||||||
|
durata: c.durata || '',
|
||||||
|
bpm: c.bpm !== undefined ? c.bpm : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.remoteCustomSongs().forEach(c => {
|
||||||
|
const id_canti = c.id_canti || Date.now();
|
||||||
|
mergedSongsMap.set(id_canti, {
|
||||||
|
id_canti,
|
||||||
|
titolo: c.titolo || 'Senza Titolo',
|
||||||
|
autore: c.autore || '',
|
||||||
|
link_youtube: c.link_youtube || '',
|
||||||
|
accordi: c.accordi || '',
|
||||||
|
momenti: c.id_momenti?.map((id: any) => String(id)) || [],
|
||||||
|
periodi: [] as string[],
|
||||||
|
testo: c.testo || '',
|
||||||
|
durata: c.durata || '',
|
||||||
|
bpm: c.bpm !== undefined ? c.bpm : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.comunitaService.comunitaCantiPersonali().forEach(c => {
|
||||||
|
const id_canti = c.id_canti || Date.now();
|
||||||
|
mergedSongsMap.set(id_canti, {
|
||||||
|
id_canti,
|
||||||
|
titolo: c.titolo || 'Senza Titolo',
|
||||||
|
autore: c.autore || '',
|
||||||
|
link_youtube: c.link_youtube || '',
|
||||||
|
accordi: c.accordi || '',
|
||||||
|
momenti: c.id_momenti?.map((id: any) => String(id)) || [],
|
||||||
|
periodi: [] as string[],
|
||||||
|
testo: c.testo || '',
|
||||||
|
durata: c.durata || '',
|
||||||
|
bpm: c.bpm !== undefined ? c.bpm : null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,97 +670,26 @@ export class PlaylistService {
|
|||||||
const activeId = this.activePlaylistId() || Date.now().toString();
|
const activeId = this.activePlaylistId() || Date.now().toString();
|
||||||
const isRemote = activeId.startsWith('remote_');
|
const isRemote = activeId.startsWith('remote_');
|
||||||
|
|
||||||
const buttons: any[] = [
|
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
|
||||||
{
|
if (isRemote) {
|
||||||
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
|
let remoteUid = uid;
|
||||||
handler: () => {
|
let remotePid = activeId;
|
||||||
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
|
const parts = activeId.split('_');
|
||||||
if (isRemote) {
|
if (parts.length >= 3) {
|
||||||
let remoteUid = uid;
|
remoteUid = parts[1];
|
||||||
let remotePid = activeId;
|
remotePid = parts[2];
|
||||||
const parts = activeId.split('_');
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
remoteUid = parts[1];
|
|
||||||
remotePid = parts[2];
|
|
||||||
}
|
|
||||||
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
|
|
||||||
}
|
|
||||||
this.executeShare(shareLink, name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
|
||||||
|
|
||||||
if (!isRemote) {
|
|
||||||
buttons.push({
|
|
||||||
text: 'Modifica (Collaborazione / Backup)',
|
|
||||||
handler: () => {
|
|
||||||
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
|
|
||||||
this.executeShare(shareLink, name + ' (Editor)');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.push({
|
const modal = await this.modalCtrl.create({
|
||||||
text: 'Annulla',
|
component: SharePlaylistQrModalComponent,
|
||||||
role: 'cancel'
|
componentProps: {
|
||||||
});
|
playlistName: name,
|
||||||
|
shareLink: shareLink
|
||||||
const alert = await this.alertCtrl.create({
|
|
||||||
header: 'Condividi Playlist',
|
|
||||||
message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:',
|
|
||||||
buttons: buttons
|
|
||||||
});
|
|
||||||
await alert.present();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeShare(shareLink: string, name: string) {
|
|
||||||
// Generate QR using the link
|
|
||||||
const qrImage = await QRCode.toDataURL(shareLink, {
|
|
||||||
width: 400,
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: '#2d3436',
|
|
||||||
light: '#ffffff'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await modal.present();
|
||||||
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(qrImage);
|
|
||||||
const blob = await res.blob();
|
|
||||||
const file = new File([blob], fileName, { type: 'image/png' });
|
|
||||||
|
|
||||||
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
|
||||||
await navigator.share({
|
|
||||||
files: [file],
|
|
||||||
title: 'Playlist CantiCristiani',
|
|
||||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback: copia il link negli appunti e scarica l'immagine del QR
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
await navigator.clipboard.writeText(shareLink);
|
|
||||||
const toast = await this.toastCtrl.create({
|
|
||||||
message: 'Link playlist copiato negli appunti! QR Code scaricato.',
|
|
||||||
duration: 3000,
|
|
||||||
color: 'success'
|
|
||||||
});
|
|
||||||
await toast.present();
|
|
||||||
}
|
|
||||||
} catch (clipErr) {
|
|
||||||
console.warn('Failed to copy link to clipboard:', clipErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = qrImage;
|
|
||||||
link.download = fileName;
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Share failed', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPlaylists() {
|
async loadPlaylists() {
|
||||||
@@ -625,4 +715,560 @@ export class PlaylistService {
|
|||||||
this.remoteShareCanti.set(updated);
|
this.remoteShareCanti.set(updated);
|
||||||
await this._storage?.set('remote_share_canti', updated);
|
await this._storage?.set('remote_share_canti', updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public printPlaylist(playlistName: string, songs: any[], showChords: boolean, transpositions?: { [songId: string]: number }) {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Impossibile aprire la finestra di stampa. Abilita i popup nel browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.myCantiService) {
|
||||||
|
this.myCantiService = this.injector.get(MyCantiService);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleHtml = playlistName ? `
|
||||||
|
<div class="cover-page">
|
||||||
|
<div class="cover-border">
|
||||||
|
<h1>Playlist: ${playlistName}</h1>
|
||||||
|
<p class="meta">Data: ${new Date().toLocaleDateString('it-IT')} • N. Canti: ${songs.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
let contentHtml = '';
|
||||||
|
|
||||||
|
for (const song of songs) {
|
||||||
|
if (!song) continue;
|
||||||
|
const isPersonal = song.id && song.id.startsWith('my_');
|
||||||
|
let songNum = '';
|
||||||
|
if (isPersonal) {
|
||||||
|
const myIndex = this.myCantiService ? this.myCantiService.myCanti().findIndex((c: any) => c.id === song.id) : -1;
|
||||||
|
songNum = myIndex !== -1 ? `Pers. ${myIndex + 1}` : 'Pers.';
|
||||||
|
} else {
|
||||||
|
songNum = song.id_canti || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there is a community number
|
||||||
|
const commCode = this.comunitaService.comunitaCode();
|
||||||
|
const commActive = this.comunitaService.isFilterActive();
|
||||||
|
let commNum = '';
|
||||||
|
if (commCode && commActive) {
|
||||||
|
const cantiInfo = this.comunitaService.comunitaCantiInfo();
|
||||||
|
const info = cantiInfo.find((x: any) => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
|
||||||
|
if (info && info.num_canto) {
|
||||||
|
commNum = info.num_canto.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayNum = commNum ? `${commNum} (${songNum})` : songNum;
|
||||||
|
|
||||||
|
// Determine the transposition amount (semitones)
|
||||||
|
let semitones = 0;
|
||||||
|
if (transpositions && transpositions[song.id] !== undefined) {
|
||||||
|
semitones = transpositions[song.id];
|
||||||
|
} else if (transpositions && transpositions[song.id_canti] !== undefined) {
|
||||||
|
semitones = transpositions[song.id_canti];
|
||||||
|
} else {
|
||||||
|
// Fallback to active playlist settings or community settings
|
||||||
|
const activePlaylistId = this.activePlaylistId();
|
||||||
|
let playlistSongSetting: any = null;
|
||||||
|
if (activePlaylistId) {
|
||||||
|
if (activePlaylistId.startsWith('remote_')) {
|
||||||
|
const pl = this.remotePlaylist();
|
||||||
|
if (pl && pl.songSettings && pl.songSettings[song.id]) {
|
||||||
|
playlistSongSetting = pl.songSettings[song.id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pl = this.playlists().find(p => p.id === activePlaylistId);
|
||||||
|
if (pl && pl.songSettings && pl.songSettings[song.id]) {
|
||||||
|
playlistSongSetting = pl.songSettings[song.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistSongSetting && playlistSongSetting.tonalita !== undefined) {
|
||||||
|
semitones = playlistSongSetting.tonalita;
|
||||||
|
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||||
|
const settings = this.comunitaService.comunitaCantiSettings();
|
||||||
|
const songSetting = settings.find(s => s.id_canti === song.id_canti || s.id_canti === Number(song.id));
|
||||||
|
if (songSetting && songSetting.tonalita !== undefined) {
|
||||||
|
semitones = songSetting.tonalita;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = showChords ? (song.accordi || song.testo) : song.testo;
|
||||||
|
let parsedSections = showChords ? this.lyricsParser.parseAccordi(rawText) : this.lyricsParser.parseText(rawText);
|
||||||
|
if (showChords && semitones !== 0) {
|
||||||
|
parsedSections = this.lyricsParser.transposeSections(parsedSections, semitones);
|
||||||
|
}
|
||||||
|
|
||||||
|
let songBodyHtml = '';
|
||||||
|
for (const section of parsedSections) {
|
||||||
|
const sectionClass = section.type === 'chorus' ? 'section-chorus' : 'section-verse';
|
||||||
|
let sectionHeader = '';
|
||||||
|
if (section.type === 'verse_num' && section.verseNumber) {
|
||||||
|
sectionHeader = `<span class="verse-number">${section.verseNumber}.</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let linesHtml = '';
|
||||||
|
for (const line of section.lines) {
|
||||||
|
let lineContentHtml = '';
|
||||||
|
if (showChords) {
|
||||||
|
for (let i = 0; i < line.segments.length; i++) {
|
||||||
|
const seg = line.segments[i];
|
||||||
|
const isCont = this.lyricsParser.isContiguousNext(line.segments, i);
|
||||||
|
const chordHtml = seg.chord ? `<span class="chord">${seg.chord}</span>` : '';
|
||||||
|
lineContentHtml += `<span class="chord-segment${isCont ? ' contiguous-next' : ''}">${chordHtml}<span class="seg-text">${seg.text || ''}</span></span>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lineContentHtml = line.text;
|
||||||
|
}
|
||||||
|
linesHtml += `<div class="lyric-line">${lineContentHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
songBodyHtml += `<div class="section-container ${sectionClass}">${sectionHeader}${linesHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentHtml += `
|
||||||
|
<div class="canto-container">
|
||||||
|
<div class="canto-header">
|
||||||
|
<div class="canto-title-row">
|
||||||
|
<span class="canto-number">${displayNum}</span>
|
||||||
|
<span class="canto-title">${song.titolo}</span>
|
||||||
|
</div>
|
||||||
|
<div class="canto-meta">
|
||||||
|
${song.autore ? `<span>Autore: ${song.autore}</span>` : ''}
|
||||||
|
${song.durata ? `<span>Durata: ${song.durata}</span>` : ''}
|
||||||
|
${song.bpm ? `<span>BPM: ${song.bpm}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="canto-body">
|
||||||
|
${songBodyHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${playlistName || 'Playlist'}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 1cm;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
.cover-page {
|
||||||
|
page-break-after: always;
|
||||||
|
width: 27.7cm;
|
||||||
|
height: 18.8cm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1.5cm;
|
||||||
|
}
|
||||||
|
.cover-border {
|
||||||
|
border: 2px solid #cc6600;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cover-border h1 {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #cc6600;
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
}
|
||||||
|
.cover-border .meta {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.canto-container {
|
||||||
|
page-break-after: always;
|
||||||
|
width: 27.7cm;
|
||||||
|
height: 18.8cm;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.canto-container:last-child {
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
.canto-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 2px solid #cc6600;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.canto-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.canto-number {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #cc6600;
|
||||||
|
}
|
||||||
|
.canto-title {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
.canto-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #777;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.canto-body {
|
||||||
|
flex-grow: 1;
|
||||||
|
column-count: 2;
|
||||||
|
column-gap: 40px;
|
||||||
|
column-fill: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 27px; /* Aumentato di 0.5em rispetto al valore iniziale di 18px per leggibilità a distanza */
|
||||||
|
}
|
||||||
|
.section-container {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.section-chorus {
|
||||||
|
border-left: 3.5px solid #cc6600;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.verse-number {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #cc6600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: block;
|
||||||
|
font-size: 18px; /* Scalato per font-size 27px */
|
||||||
|
}
|
||||||
|
.lyric-line {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
min-height: 1.2em;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
.chord-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
vertical-align: bottom;
|
||||||
|
margin-right: 0.22em;
|
||||||
|
}
|
||||||
|
.chord-segment.contiguous-next {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
.chord {
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b35900;
|
||||||
|
height: 1.3em;
|
||||||
|
margin-bottom: -0.1em;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.seg-text {
|
||||||
|
white-space: pre;
|
||||||
|
.close-btn-web {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #cc6600;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 9999;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.close-btn-web:hover {
|
||||||
|
background: #b35900;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
margin: 1cm;
|
||||||
|
size: A4 landscape;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.canto-container {
|
||||||
|
height: 18.8cm;
|
||||||
|
}
|
||||||
|
.close-btn-web {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button onclick="window.close()" class="close-btn-web">Chiudi</button>
|
||||||
|
${titleHtml}
|
||||||
|
${contentHtml}
|
||||||
|
<script>
|
||||||
|
window.onload = () => {
|
||||||
|
const containers = Array.from(document.querySelectorAll('.canto-container'));
|
||||||
|
|
||||||
|
containers.forEach(container => {
|
||||||
|
const baseBody = container.querySelector('.canto-body');
|
||||||
|
if (!baseBody) return;
|
||||||
|
|
||||||
|
// Salva l'HTML originale per poterlo ripristinare intatto a ogni ciclo di ridimensionamento
|
||||||
|
let originalHtml = container.getAttribute('data-original-html');
|
||||||
|
if (!originalHtml) {
|
||||||
|
originalHtml = baseBody.innerHTML;
|
||||||
|
container.setAttribute('data-original-html', originalHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fontSize = 27; // Base 27px (+0.5em)
|
||||||
|
|
||||||
|
const overflows = (el) => {
|
||||||
|
return el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLayoutSplit = () => {
|
||||||
|
// Rimuovi eventuali pagine dinamiche create in precedenza per questo canto
|
||||||
|
let next = container.nextSibling;
|
||||||
|
while (next && next.classList && next.classList.contains('dynamic-canto-page')) {
|
||||||
|
const toRemove = next;
|
||||||
|
next = next.nextSibling;
|
||||||
|
toRemove.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ripristina l'HTML originale intatto nella prima pagina
|
||||||
|
baseBody.innerHTML = originalHtml;
|
||||||
|
|
||||||
|
container.style.fontSize = fontSize + 'px';
|
||||||
|
baseBody.style.fontSize = fontSize + 'px';
|
||||||
|
|
||||||
|
const pages = [{ container, body: baseBody }];
|
||||||
|
let currentPageIndex = 0;
|
||||||
|
|
||||||
|
// Distribuisci il testo riga per riga (o sezione per sezione) creando nuove pagine
|
||||||
|
while (currentPageIndex < pages.length) {
|
||||||
|
const currentPage = pages[currentPageIndex];
|
||||||
|
|
||||||
|
if (overflows(currentPage.body)) {
|
||||||
|
let nextPageIndex = currentPageIndex + 1;
|
||||||
|
let nextPage = pages[nextPageIndex];
|
||||||
|
|
||||||
|
if (!nextPage) {
|
||||||
|
const newPageContainer = document.createElement('div');
|
||||||
|
newPageContainer.className = 'canto-container dynamic-canto-page';
|
||||||
|
|
||||||
|
const header = container.querySelector('.canto-header').cloneNode(true);
|
||||||
|
newPageContainer.appendChild(header);
|
||||||
|
|
||||||
|
const newBody = document.createElement('div');
|
||||||
|
newBody.className = 'canto-body';
|
||||||
|
newPageContainer.appendChild(newBody);
|
||||||
|
|
||||||
|
// Inserisci dopo l'ultima pagina inserita
|
||||||
|
const lastPageContainer = pages[pages.length - 1].container;
|
||||||
|
lastPageContainer.parentNode.insertBefore(newPageContainer, lastPageContainer.nextSibling);
|
||||||
|
|
||||||
|
nextPage = { container: newPageContainer, body: newBody };
|
||||||
|
pages.push(nextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imposta font-size coerente sulla nuova pagina
|
||||||
|
nextPage.container.style.fontSize = fontSize + 'px';
|
||||||
|
nextPage.body.style.fontSize = fontSize + 'px';
|
||||||
|
|
||||||
|
// Sposta elementi riga per riga per sfruttare al millimetro lo spazio
|
||||||
|
let safetyCounter = 0;
|
||||||
|
while (overflows(currentPage.body) && safetyCounter < 300) {
|
||||||
|
safetyCounter++;
|
||||||
|
const childSections = Array.from(currentPage.body.children);
|
||||||
|
if (childSections.length === 0) break;
|
||||||
|
|
||||||
|
const lastSection = childSections[childSections.length - 1];
|
||||||
|
const lyricLines = Array.from(lastSection.querySelectorAll('.lyric-line'));
|
||||||
|
|
||||||
|
// Recupera o assegna un ID univoco a questa sezione
|
||||||
|
let sectionId = lastSection.getAttribute('data-section-id');
|
||||||
|
if (!sectionId) {
|
||||||
|
sectionId = 'sec-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
lastSection.setAttribute('data-section-id', sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerca se esiste già la sezione corrispondente sulla pagina successiva
|
||||||
|
let targetSection = nextPage.body.querySelector('[data-section-id="' + sectionId + '"]');
|
||||||
|
if (!targetSection) {
|
||||||
|
targetSection = document.createElement('div');
|
||||||
|
targetSection.className = lastSection.className;
|
||||||
|
targetSection.setAttribute('data-section-id', sectionId);
|
||||||
|
nextPage.body.insertBefore(targetSection, nextPage.body.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lyricLines.length > 1) {
|
||||||
|
// Spezza la sezione: sposta solo l'ultima riga all'inizio di targetSection
|
||||||
|
const lastLine = lyricLines[lyricLines.length - 1];
|
||||||
|
targetSection.insertBefore(lastLine, targetSection.firstChild);
|
||||||
|
} else {
|
||||||
|
// Sposta tutti i restanti nodi (inclusi eventuali numeri di strofa o l'unica riga rimasta)
|
||||||
|
while (lastSection.firstChild) {
|
||||||
|
targetSection.insertBefore(lastSection.lastChild, targetSection.firstChild);
|
||||||
|
}
|
||||||
|
lastSection.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evita loop infiniti se una singola riga è più grande dell'intera pagina
|
||||||
|
const remaining = Array.from(currentPage.body.children);
|
||||||
|
if (remaining.length === 1) {
|
||||||
|
const remLines = remaining[0].querySelectorAll('.lyric-line');
|
||||||
|
if (remLines.length <= 1) {
|
||||||
|
// Sposta gli ultimi frammenti rimasti
|
||||||
|
let lastSec = remaining[0];
|
||||||
|
let secId = lastSec.getAttribute('data-section-id') || 'sec-last';
|
||||||
|
let finalTarget = nextPage.body.querySelector('[data-section-id="' + secId + '"]');
|
||||||
|
if (!finalTarget) {
|
||||||
|
finalTarget = document.createElement('div');
|
||||||
|
finalTarget.className = lastSec.className;
|
||||||
|
finalTarget.setAttribute('data-section-id', secId);
|
||||||
|
nextPage.body.insertBefore(finalTarget, nextPage.body.firstChild);
|
||||||
|
}
|
||||||
|
while (lastSec.firstChild) {
|
||||||
|
finalTarget.insertBefore(lastSec.lastChild, finalTarget.firstChild);
|
||||||
|
}
|
||||||
|
lastSec.remove();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPageIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna le intestazioni con l'indice di pagina (es. 1 di 3, 2 di 3...)
|
||||||
|
const totalPages = pages.length;
|
||||||
|
pages.forEach((p, idx) => {
|
||||||
|
const titleElem = p.container.querySelector('.canto-title');
|
||||||
|
if (titleElem) {
|
||||||
|
let origTitle = titleElem.getAttribute('data-original-title');
|
||||||
|
if (!origTitle) {
|
||||||
|
origTitle = titleElem.innerHTML.split('<span')[0].trim();
|
||||||
|
titleElem.setAttribute('data-original-title', origTitle);
|
||||||
|
}
|
||||||
|
if (totalPages > 1) {
|
||||||
|
titleElem.innerHTML = origTitle + ' <span style="font-size: 0.65em; color: #777; font-weight: 400;">(' + (idx + 1) + ' di ' + totalPages + ')</span>';
|
||||||
|
} else {
|
||||||
|
titleElem.innerHTML = origTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSongPages = () => {
|
||||||
|
const pages = [container];
|
||||||
|
let next = container.nextSibling;
|
||||||
|
while (next && next.classList && next.classList.contains('dynamic-canto-page')) {
|
||||||
|
pages.push(next);
|
||||||
|
next = next.nextSibling;
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Esegui la suddivisione. Se qualcuna delle pagine risultanti trabocca, o
|
||||||
|
// se il canto supera il totale delle pagine massime consigliate, o
|
||||||
|
// se l'ultima pagina contiene solo 1 o 2 righe (orfane), riduci gradualmente il font size.
|
||||||
|
const checkAnyOverflow = () => {
|
||||||
|
const songPages = getSongPages();
|
||||||
|
|
||||||
|
// 1. Controlla se una pagina qualsiasi trabocca internamente
|
||||||
|
const hasInternalOverflow = songPages.some(p => {
|
||||||
|
const b = p.querySelector('.canto-body');
|
||||||
|
return b && overflows(b);
|
||||||
|
});
|
||||||
|
if (hasInternalOverflow) return true;
|
||||||
|
|
||||||
|
// 2. Limite pagine dinamico: preferisci massimo 3 pagine, consenti 4 o 5 solo se il carattere è già piccolo
|
||||||
|
let maxAllowedPages = 3;
|
||||||
|
if (fontSize <= 17) {
|
||||||
|
maxAllowedPages = 4;
|
||||||
|
}
|
||||||
|
if (fontSize <= 14) {
|
||||||
|
maxAllowedPages = 5;
|
||||||
|
}
|
||||||
|
if (songPages.length > maxAllowedPages) {
|
||||||
|
return true; // Supera il limite massimo consigliato per questo font size, forza la riduzione del font
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Se ci sono più pagine, controlla se l'ultima pagina ha solo 1 o 2 righe di testo
|
||||||
|
if (songPages.length > 1) {
|
||||||
|
const lastPage = songPages[songPages.length - 1];
|
||||||
|
const lastPageBody = lastPage.querySelector('.canto-body');
|
||||||
|
if (lastPageBody) {
|
||||||
|
const lyricLines = lastPageBody.querySelectorAll('.lyric-line').length;
|
||||||
|
if (lyricLines > 0 && lyricLines <= 2) {
|
||||||
|
return true; // Forza la riduzione del font per tirare su queste righe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
doLayoutSplit();
|
||||||
|
|
||||||
|
while (checkAnyOverflow() && fontSize > 12) {
|
||||||
|
fontSize -= 0.5;
|
||||||
|
doLayoutSplit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write(html);
|
||||||
|
printWindow.document.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,18 @@ export class SettingsService {
|
|||||||
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
||||||
public showUpdateDate = signal<boolean>(true);
|
public showUpdateDate = signal<boolean>(true);
|
||||||
|
|
||||||
|
/** Visualizza durata, bpm e tonalità sotto autore / titolo: true = attivo */
|
||||||
|
public showDurationBpmTonality = signal<boolean>(true);
|
||||||
|
|
||||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
||||||
public enableStandardAutoscroll = signal<boolean>(false);
|
public enableStandardAutoscroll = signal<boolean>(false);
|
||||||
|
|
||||||
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
|
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
|
||||||
public enableVisualAutoscroll = signal<boolean>(true);
|
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 */
|
/** Preferenza notazione accordi: diesis o bemolle */
|
||||||
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
||||||
|
|
||||||
@@ -54,7 +60,13 @@ export class SettingsService {
|
|||||||
public karaokePageScrollMode = signal<boolean>(false);
|
public karaokePageScrollMode = signal<boolean>(false);
|
||||||
|
|
||||||
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
|
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
|
||||||
public landscapeProjectionEnabled = signal<boolean>(true);
|
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à */
|
/** Identificativo utente univoco per la gestione delle comunità */
|
||||||
public userUuid = signal<string>('');
|
public userUuid = signal<string>('');
|
||||||
@@ -128,6 +140,8 @@ export class SettingsService {
|
|||||||
this.deferredPrompt.set(e);
|
this.deferredPrompt.set(e);
|
||||||
// Update UI notify the user they can install the PWA
|
// Update UI notify the user they can install the PWA
|
||||||
this.showInstallButton.set(true);
|
this.showInstallButton.set(true);
|
||||||
|
// Se scatta l'evento di installazione, l'app NON è attualmente installata
|
||||||
|
localStorage.setItem('pwa-installed', 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('appinstalled', () => {
|
window.addEventListener('appinstalled', () => {
|
||||||
@@ -149,6 +163,7 @@ export class SettingsService {
|
|||||||
localStorage.setItem('invio-dati-statistici', 'false');
|
localStorage.setItem('invio-dati-statistici', 'false');
|
||||||
localStorage.setItem('show-tags-in-list', 'true');
|
localStorage.setItem('show-tags-in-list', 'true');
|
||||||
localStorage.setItem('show-update-date', 'true');
|
localStorage.setItem('show-update-date', 'true');
|
||||||
|
localStorage.setItem('show-duration-bpm-tonality', 'true');
|
||||||
localStorage.setItem('enable-standard-autoscroll', 'false');
|
localStorage.setItem('enable-standard-autoscroll', 'false');
|
||||||
localStorage.setItem('enable-visual-autoscroll', 'true');
|
localStorage.setItem('enable-visual-autoscroll', 'true');
|
||||||
localStorage.setItem('chord-notation-preference', 'diesis');
|
localStorage.setItem('chord-notation-preference', 'diesis');
|
||||||
@@ -219,6 +234,13 @@ export class SettingsService {
|
|||||||
this.showUpdateDate.set(true);
|
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');
|
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
||||||
if (savedStandardAutoscroll !== null) {
|
if (savedStandardAutoscroll !== null) {
|
||||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||||
@@ -251,7 +273,22 @@ export class SettingsService {
|
|||||||
if (savedLandscapeProjectionEnabled !== null) {
|
if (savedLandscapeProjectionEnabled !== null) {
|
||||||
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
|
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
|
||||||
} else {
|
} else {
|
||||||
this.landscapeProjectionEnabled.set(true);
|
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)
|
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||||
@@ -291,6 +328,10 @@ export class SettingsService {
|
|||||||
localStorage.setItem('show-update-date', this.showUpdateDate().toString());
|
localStorage.setItem('show-update-date', this.showUpdateDate().toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
localStorage.setItem('show-duration-bpm-tonality', this.showDurationBpmTonality().toString());
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
|
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
|
||||||
});
|
});
|
||||||
@@ -311,6 +352,25 @@ export class SettingsService {
|
|||||||
localStorage.setItem('landscape-projection-enabled', this.landscapeProjectionEnabled().toString());
|
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(() => {
|
effect(() => {
|
||||||
const active = this.keepScreenOn();
|
const active = this.keepScreenOn();
|
||||||
localStorage.setItem('keep-screen-on', active.toString());
|
localStorage.setItem('keep-screen-on', active.toString());
|
||||||
@@ -422,6 +482,12 @@ export class SettingsService {
|
|||||||
localStorage.setItem('show-update-date', newValue.toString());
|
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() {
|
toggleStandardAutoscroll() {
|
||||||
const newValue = !this.enableStandardAutoscroll();
|
const newValue = !this.enableStandardAutoscroll();
|
||||||
this.enableStandardAutoscroll.set(newValue);
|
this.enableStandardAutoscroll.set(newValue);
|
||||||
@@ -446,6 +512,12 @@ export class SettingsService {
|
|||||||
localStorage.setItem('landscape-projection-enabled', newValue.toString());
|
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') {
|
setChordNotationPreference(val: 'diesis' | 'bemolle') {
|
||||||
this.chordNotationPreference.set(val);
|
this.chordNotationPreference.set(val);
|
||||||
}
|
}
|
||||||
@@ -464,10 +536,10 @@ export class SettingsService {
|
|||||||
localStorage.setItem('user-name', trimmed);
|
localStorage.setItem('user-name', trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
async installPwa() {
|
async installPwa(): Promise<string | undefined> {
|
||||||
const promptEvent = this.deferredPrompt();
|
const promptEvent = this.deferredPrompt();
|
||||||
if (!promptEvent) {
|
if (!promptEvent) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
// Show the install prompt
|
// Show the install prompt
|
||||||
promptEvent.prompt();
|
promptEvent.prompt();
|
||||||
@@ -477,5 +549,6 @@ export class SettingsService {
|
|||||||
// We've used the prompt, and can't use it again, discard it
|
// We've used the prompt, and can't use it again, discard it
|
||||||
this.deferredPrompt.set(null);
|
this.deferredPrompt.set(null);
|
||||||
this.showInstallButton.set(false);
|
this.showInstallButton.set(false);
|
||||||
|
return outcome;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MediaSessionService } from './media-session.service';
|
|||||||
import { MyCantiService } from './my-canti.service';
|
import { MyCantiService } from './my-canti.service';
|
||||||
import { ConnectivityService } from './connectivity.service';
|
import { ConnectivityService } from './connectivity.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
|
import { PlaylistService } from './playlist.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -16,6 +17,7 @@ export class YoutubePlayerService {
|
|||||||
private myCantiService = inject(MyCantiService);
|
private myCantiService = inject(MyCantiService);
|
||||||
private connectivityService = inject(ConnectivityService);
|
private connectivityService = inject(ConnectivityService);
|
||||||
private comunitaService = inject(ComunitaService);
|
private comunitaService = inject(ComunitaService);
|
||||||
|
private playlistService = inject(PlaylistService);
|
||||||
|
|
||||||
public isPlayerSupported = computed<boolean>(() => {
|
public isPlayerSupported = computed<boolean>(() => {
|
||||||
// Check if offline
|
// Check if offline
|
||||||
@@ -48,8 +50,9 @@ export class YoutubePlayerService {
|
|||||||
// Auto-stop player when not supported (e.g. going offline)
|
// Auto-stop player when not supported (e.g. going offline)
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const supported = this.isPlayerSupported();
|
const supported = this.isPlayerSupported();
|
||||||
if (!supported && this.currentCantoId()) {
|
if (!supported) {
|
||||||
this.stop();
|
this.isPlaying.set(false);
|
||||||
|
this.destroyPlayer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,8 +106,8 @@ export class YoutubePlayerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void) {
|
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void, customYoutubeLink?: string) {
|
||||||
if (this.currentCantoId() === cantoId && this.player) {
|
if (this.currentCantoId() === cantoId && this.player && !customYoutubeLink) {
|
||||||
this.onEndedCallback = onEnded || null;
|
this.onEndedCallback = onEnded || null;
|
||||||
this.onErrorCallback = onError || null;
|
this.onErrorCallback = onError || null;
|
||||||
|
|
||||||
@@ -120,30 +123,55 @@ export class YoutubePlayerService {
|
|||||||
this.onEndedCallback = onEnded || null;
|
this.onEndedCallback = onEnded || null;
|
||||||
this.onErrorCallback = onError || null;
|
this.onErrorCallback = onError || null;
|
||||||
|
|
||||||
let canto = this.cantiService.getCantoById(cantoId);
|
let videoId: string | null = null;
|
||||||
if (!canto) {
|
if (customYoutubeLink) {
|
||||||
canto = this.myCantiService.myCanti().find(c => c.id === cantoId);
|
videoId = this.cantiService.getYoutubeId(customYoutubeLink);
|
||||||
|
} else {
|
||||||
|
let canto = this.cantiService.getCantoById(cantoId);
|
||||||
|
if (!canto) {
|
||||||
|
canto = this.myCantiService.myCanti().find(c => c.id === cantoId);
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!canto) {
|
|
||||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
|
||||||
}
|
|
||||||
if (!canto) return;
|
|
||||||
|
|
||||||
const videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
|
||||||
if (!videoId) {
|
if (!videoId) {
|
||||||
if (onError) onError();
|
if (onError) onError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(window as any).YT || !(window as any).YT.Player) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse existing player if possible
|
// Reuse existing player if possible
|
||||||
if (this.player && this.player.loadVideoById) {
|
if (this.player && this.player.loadVideoById) {
|
||||||
this.currentCantoId.set(cantoId);
|
this.currentCantoId.set(cantoId);
|
||||||
this.mediaSessionService.updateMetadata(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({
|
this.player.loadVideoById({
|
||||||
videoId: videoId,
|
videoId: videoId,
|
||||||
startSeconds: startTime
|
startSeconds: startTime
|
||||||
@@ -154,7 +182,20 @@ export class YoutubePlayerService {
|
|||||||
|
|
||||||
this.destroyPlayer();
|
this.destroyPlayer();
|
||||||
this.currentCantoId.set(cantoId);
|
this.currentCantoId.set(cantoId);
|
||||||
this.mediaSessionService.updateMetadata(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', {
|
this.player = new (window as any).YT.Player('global-yt-player-container', {
|
||||||
height: '1',
|
height: '1',
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = '2026.06.17.0049';
|
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
+8
-3
@@ -1,8 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="it" class="notranslate">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="google" content="notranslate"/>
|
||||||
<title>CantiCristiani</title>
|
<title>CantiCristiani</title>
|
||||||
|
|
||||||
<base href="/"/>
|
<base href="/"/>
|
||||||
@@ -73,7 +74,11 @@
|
|||||||
if (options.percent !== undefined) {
|
if (options.percent !== undefined) {
|
||||||
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
|
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
|
||||||
if (bar) bar.style.width = pct + '%';
|
if (bar) bar.style.width = pct + '%';
|
||||||
if (pctText) pctText.textContent = pct + '%';
|
if (bar && bar.parentElement) bar.parentElement.style.display = 'block';
|
||||||
|
if (pctText) {
|
||||||
|
pctText.style.display = 'block';
|
||||||
|
pctText.textContent = pct + '%';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.isRedirect) {
|
if (options.isRedirect) {
|
||||||
@@ -82,7 +87,7 @@
|
|||||||
if (phaseEl) phaseEl.style.display = 'none';
|
if (phaseEl) phaseEl.style.display = 'none';
|
||||||
if (versionEl) versionEl.style.display = 'none';
|
if (versionEl) versionEl.style.display = 'none';
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device';
|
titleEl.textContent = 'Chiudi il browser è stata installata la app sul device';
|
||||||
titleEl.style.fontSize = '1.4rem';
|
titleEl.style.fontSize = '1.4rem';
|
||||||
titleEl.style.lineHeight = '1.5';
|
titleEl.style.lineHeight = '1.5';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user