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.
|
||||
+23
-10
@@ -29,25 +29,38 @@ if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Rotazione file su FTP ---
|
||||
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
||||
# Rinominiamo canti.json in canti_ex.json.
|
||||
# Usiamo i percorsi assoluti per sicurezza.
|
||||
curl -s -u "$FTP_USER:$FTP_PASS" \
|
||||
# --- Rotazione e Caricamento file (FTP o VPS) ---
|
||||
if [ -n "$VPS_HOST" ]; then
|
||||
echo "🔄 Rotazione file su VPS ($VPS_HOST)..."
|
||||
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api && mv $VPS_PATH/api/canti.json $VPS_PATH/api/canti_ex.json 2>/dev/null || true"
|
||||
|
||||
echo "🚀 Caricamento nuovo file su VPS via SCP..."
|
||||
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento su VPS completato con successo!"
|
||||
else
|
||||
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
||||
# Rinominiamo canti.json in canti_ex.json.
|
||||
# Usiamo i percorsi assoluti per sicurezza.
|
||||
curl -s -u "$FTP_USER:$FTP_PASS" \
|
||||
--ftp-pasv \
|
||||
-Q "*DELE /htdocs/api/canti_ex.json" \
|
||||
-Q "*RNFR /htdocs/api/canti.json" \
|
||||
-Q "*RNTO /htdocs/api/canti_ex.json" \
|
||||
"ftp://$FTP_HOST/" > /dev/null
|
||||
|
||||
# --- Upload via FTP ---
|
||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento completato con successo!"
|
||||
else
|
||||
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento su FTP completato con successo!"
|
||||
else
|
||||
echo "❌ Errore durante il caricamento FTP."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
|
||||
+16
-4
@@ -29,14 +29,26 @@ if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Upload via FTP ---
|
||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento completato con successo!"
|
||||
# --- Caricamento file (FTP o VPS) ---
|
||||
if [ -n "$VPS_HOST" ]; then
|
||||
echo "🚀 Caricamento nuovo file su VPS ($VPS_HOST) via SCP..."
|
||||
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api"
|
||||
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento su VPS completato con successo!"
|
||||
else
|
||||
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento su FTP completato con successo!"
|
||||
else
|
||||
echo "❌ Errore durante il caricamento FTP."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "16kb",
|
||||
"maximumError": "25kb"
|
||||
"maximumWarning": "32kb",
|
||||
"maximumError": "48kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
|
||||
+11
-37
@@ -8,41 +8,14 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Configurazione Target e Parallelismo ---
|
||||
TARGET="tophost"
|
||||
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
|
||||
|
||||
# --- Configurazione Target ---
|
||||
TARGET="contabo"
|
||||
echo "🎯 Target di deploy: $TARGET"
|
||||
|
||||
# --- Validazione configurazione basata sul target ---
|
||||
if [ "$TARGET" = "tophost" ]; then
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
REMOTE_DIR=""
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
|
||||
# --- Validazione configurazione ---
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
|
||||
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Aggiornamento Versione ---
|
||||
@@ -84,10 +57,11 @@ echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.j
|
||||
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||
|
||||
# --- Upload ---
|
||||
if [ "$TARGET" = "tophost" ]; then
|
||||
echo "🚀 Upload via FTP su Tophost in corso..."
|
||||
python3 scratch/deploy_ftp.py
|
||||
else
|
||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||
rsync -avz --delete www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||
|
||||
# --- Allineamento Cantiletture JSON ---
|
||||
if [ -f "./allinealetture.sh" ]; then
|
||||
echo "🔄 Sincronizzazione cantiletture.json su server..."
|
||||
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
|
||||
fi
|
||||
|
||||
@@ -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": "/",
|
||||
"theme_color": "#3880ff",
|
||||
"background_color": "#ffffff",
|
||||
"launch_handler": {
|
||||
"client_mode": "focus-existing"
|
||||
},
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"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));
|
||||
@@ -4,12 +4,28 @@
|
||||
|
||||
<!-- PWA Redirect Overlay -->
|
||||
<div *ngIf="showRedirectOverlay()"
|
||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||
<div style="margin-bottom: 25px; display: inline-block;">
|
||||
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||
</div>
|
||||
|
||||
<!-- Se la PWA è rilevata come installata -->
|
||||
<ng-container *ngIf="isPwaInstalled()">
|
||||
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; color: #ffffff; -webkit-font-smoothing: antialiased;">Applicazione Installata</h2>
|
||||
<div style="font-size: 1.05rem; color: rgba(255, 255, 255, 0.9); line-height: 1.6; -webkit-font-smoothing: antialiased; text-align: center; padding: 0 10px; margin-bottom: 25px;">
|
||||
La app risulta già installata sul dispositivo, chiudi il browser ed usa quella oppure disinstallala se preferisci utilizzarla da qui.
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||
<button (click)="stayInBrowserForceUninstallCheck()"
|
||||
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
|
||||
Continua nel browser (L'ho disinstallata)
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Se l'applicazione NON è installata (flusso normale di redirect / installazione guidata) -->
|
||||
<ng-container *ngIf="!isPwaInstalled()">
|
||||
<!-- Fase di redirect normale (in attesa di apertura) -->
|
||||
<ng-container *ngIf="!redirectFailed()">
|
||||
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
|
||||
@@ -21,10 +37,10 @@
|
||||
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
||||
Apri Manualmente
|
||||
</button>
|
||||
<button (click)="stayInBrowser()"
|
||||
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
|
||||
Rimani nel browser
|
||||
</button>
|
||||
<div 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>
|
||||
|
||||
@@ -74,12 +90,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PWA Install Overlay -->
|
||||
<div *ngIf="showInstallOverlay()"
|
||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: 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 -->
|
||||
<div *ngIf="settingsService.isAndroid()"
|
||||
|
||||
+440
-111
@@ -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 { CantiService } from './services/canti.service';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { VERSION } from './version';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { Router, ActivatedRoute, NavigationStart } from '@angular/router';
|
||||
import { ToastController, Platform, AlertController, NavController } from '@ionic/angular';
|
||||
import { Location } from '@angular/common';
|
||||
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
import { App } from '@capacitor/app';
|
||||
import { addIcons } from 'ionicons';
|
||||
import {
|
||||
add,
|
||||
addCircleOutline,
|
||||
addOutline,
|
||||
analyticsOutline,
|
||||
arrowUpCircle,
|
||||
bookOutline,
|
||||
calendarOutline,
|
||||
cameraOutline,
|
||||
cameraReverseOutline,
|
||||
carOutline,
|
||||
chevronBack,
|
||||
chevronBackOutline,
|
||||
chevronDown,
|
||||
chevronDownOutline,
|
||||
chevronForward,
|
||||
chevronForwardOutline,
|
||||
chevronUp,
|
||||
chevronUpOutline,
|
||||
closeCircle,
|
||||
closeCircleOutline,
|
||||
closeOutline,
|
||||
cloudDownloadOutline,
|
||||
cloudOfflineOutline,
|
||||
cloudUploadOutline,
|
||||
contrastOutline,
|
||||
copyOutline,
|
||||
createOutline,
|
||||
documentAttachOutline,
|
||||
documentTextOutline,
|
||||
downloadOutline,
|
||||
eyeOutline,
|
||||
informationCircleOutline,
|
||||
keypadOutline,
|
||||
listOutline,
|
||||
logoApple,
|
||||
logoYoutube,
|
||||
mic,
|
||||
micOutline,
|
||||
musicalNote,
|
||||
musicalNotesOutline,
|
||||
pause,
|
||||
pauseSharp,
|
||||
peopleOutline,
|
||||
personOutline,
|
||||
phoneLandscapeOutline,
|
||||
play,
|
||||
playForwardOutline,
|
||||
playSharp,
|
||||
playSkipBackSharp,
|
||||
playSkipForwardSharp,
|
||||
pricetagsOutline,
|
||||
qrCodeOutline,
|
||||
refreshOutline,
|
||||
remove,
|
||||
removeCircleOutline,
|
||||
removeOutline,
|
||||
reorderTwoOutline,
|
||||
saveOutline,
|
||||
scanOutline,
|
||||
searchOutline,
|
||||
settingsOutline,
|
||||
shareOutline,
|
||||
shareSocialOutline,
|
||||
sparklesOutline,
|
||||
statsChartOutline,
|
||||
swapVerticalOutline,
|
||||
trashOutline,
|
||||
arrowUndoOutline,
|
||||
videocam,
|
||||
videocamOffOutline
|
||||
} from 'ionicons/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -19,21 +94,105 @@ export class AppComponent implements OnInit {
|
||||
public cantiService = inject(CantiService);
|
||||
public settingsService = inject(SettingsService);
|
||||
public version = VERSION;
|
||||
private platform = inject(Platform);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private location = inject(Location);
|
||||
private navCtrl = inject(NavController);
|
||||
private toastCtrl = inject(ToastController);
|
||||
private alertCtrl = inject(AlertController);
|
||||
private swUpdate = inject(SwUpdate);
|
||||
private ngZone = inject(NgZone);
|
||||
|
||||
public showRedirectOverlay = signal<boolean>(false);
|
||||
public showInstallOverlay = signal<boolean>(false);
|
||||
public isInstalling = signal<boolean>(false);
|
||||
public isRedirecting = signal<boolean>(false);
|
||||
public isPwaInstalled = signal<boolean>(false);
|
||||
public redirectFailed = signal<boolean>(false);
|
||||
public protocolLink = '';
|
||||
|
||||
|
||||
|
||||
|
||||
constructor() {
|
||||
// Gli aggiornamenti automatici e periodici sono stati rimossi.
|
||||
// L'aggiornamento viene gestito esclusivamente in modo manuale
|
||||
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
|
||||
|
||||
addIcons({
|
||||
add,
|
||||
'add-circle-outline': addCircleOutline,
|
||||
'add-outline': addOutline,
|
||||
'analytics-outline': analyticsOutline,
|
||||
'arrow-up-circle': arrowUpCircle,
|
||||
'book-outline': bookOutline,
|
||||
'calendar-outline': calendarOutline,
|
||||
'camera-outline': cameraOutline,
|
||||
'camera-reverse-outline': cameraReverseOutline,
|
||||
'car-outline': carOutline,
|
||||
'chevron-back': chevronBack,
|
||||
'chevron-back-outline': chevronBackOutline,
|
||||
'chevron-down': chevronDown,
|
||||
'chevron-down-outline': chevronDownOutline,
|
||||
'chevron-forward': chevronForward,
|
||||
'chevron-forward-outline': chevronForwardOutline,
|
||||
'chevron-up': chevronUp,
|
||||
'chevron-up-outline': chevronUpOutline,
|
||||
'close-circle': closeCircle,
|
||||
'close-circle-outline': closeCircleOutline,
|
||||
'close-outline': closeOutline,
|
||||
'cloud-download-outline': cloudDownloadOutline,
|
||||
'cloud-offline-outline': cloudOfflineOutline,
|
||||
'cloud-upload-outline': cloudUploadOutline,
|
||||
'contrast-outline': contrastOutline,
|
||||
'copy-outline': copyOutline,
|
||||
'create-outline': createOutline,
|
||||
'document-attach-outline': documentAttachOutline,
|
||||
'document-text-outline': documentTextOutline,
|
||||
'download-outline': downloadOutline,
|
||||
'eye-outline': eyeOutline,
|
||||
'information-circle-outline': informationCircleOutline,
|
||||
'keypad-outline': keypadOutline,
|
||||
'list-outline': listOutline,
|
||||
'logo-apple': logoApple,
|
||||
'logo-youtube': logoYoutube,
|
||||
mic,
|
||||
'mic-outline': micOutline,
|
||||
'musical-note': musicalNote,
|
||||
'musical-notes-outline': musicalNotesOutline,
|
||||
pause,
|
||||
'pause-sharp': pauseSharp,
|
||||
'people-outline': peopleOutline,
|
||||
'person-outline': personOutline,
|
||||
'phone-landscape-outline': phoneLandscapeOutline,
|
||||
play,
|
||||
'play-forward-outline': playForwardOutline,
|
||||
'play-sharp': playSharp,
|
||||
'play-skip-back-sharp': playSkipBackSharp,
|
||||
'play-skip-forward-sharp': playSkipForwardSharp,
|
||||
'pricetags-outline': pricetagsOutline,
|
||||
'qr-code-outline': qrCodeOutline,
|
||||
'refresh-outline': refreshOutline,
|
||||
remove,
|
||||
'remove-circle-outline': removeCircleOutline,
|
||||
'remove-outline': removeOutline,
|
||||
'reorder-two-outline': reorderTwoOutline,
|
||||
'save-outline': saveOutline,
|
||||
'scan-outline': scanOutline,
|
||||
'search-outline': searchOutline,
|
||||
'settings-outline': settingsOutline,
|
||||
'share-outline': shareOutline,
|
||||
'share-social-outline': shareSocialOutline,
|
||||
'sparkles-outline': sparklesOutline,
|
||||
'stats-chart-outline': statsChartOutline,
|
||||
'swap-vertical-outline': swapVerticalOutline,
|
||||
'trash-outline': trashOutline,
|
||||
'arrow-undo-outline': arrowUndoOutline,
|
||||
videocam,
|
||||
'videocam-off-outline': videocamOffOutline
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.checkLoaderDismissal();
|
||||
});
|
||||
@@ -45,19 +204,59 @@ export class AppComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Se l'overlay di redirect o di installazione è mostrato, NON nascondiamo il loader iniziale
|
||||
// per rimanere nella welcome page mentre l'utente sceglie.
|
||||
if (this.showRedirectOverlay() || this.showInstallOverlay()) {
|
||||
// Se stiamo attivamente installando o reindirizzando, NON nascondiamo il loader
|
||||
if (this.isInstalling() || this.isRedirecting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Altrimenti, nascondiamo il loader per far entrare l'utente nell'app
|
||||
// Altrimenti, nascondiamo il loader per far entrare l'utente
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// Gestione del tasto back (PWA/Browser e Nativo/Hardware) secondo le 3 specifiche:
|
||||
// 1- Se siamo su un canto (/player o /display), premendo back andiamo sempre sulla home.
|
||||
// 2- Se siamo sulla home (/home o /), premendo back l'app deve uscire.
|
||||
// 3- Per tutto il resto, segue la logica standard andando alla pagina precedente nello storico.
|
||||
|
||||
// Gestione popstate (tasto indietro browser / gesture PWA)
|
||||
this.router.events.pipe(
|
||||
filter((e): e is NavigationStart => e instanceof NavigationStart),
|
||||
filter(e => e.navigationTrigger === 'popstate')
|
||||
).subscribe(() => {
|
||||
const currentPath = this.router.url.split('?')[0];
|
||||
if (currentPath === '/home' || currentPath === '/') {
|
||||
// Spec 2: Sulla home, usciamo dall'app
|
||||
this.router.navigate(['/home'], { replaceUrl: true });
|
||||
this.exitApp();
|
||||
} else if (currentPath === '/player' || currentPath === '/display') {
|
||||
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
|
||||
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||
}
|
||||
// Spec 3: Per il resto (es. /settings), popstate prosegue normalmente verso la pagina precedente
|
||||
});
|
||||
|
||||
// Gestione tasto indietro hardware (es. Android / Capacitor / PWA)
|
||||
this.platform.backButton.subscribeWithPriority(10, async () => {
|
||||
const currentPath = this.router.url.split('?')[0];
|
||||
if (currentPath === '/home' || currentPath === '/') {
|
||||
// Spec 2: Sulla home, usciamo dall'app
|
||||
this.exitApp();
|
||||
} else if (currentPath === '/player' || currentPath === '/display') {
|
||||
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
|
||||
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||
} else {
|
||||
// Spec 3: Per il resto, logica standard (pagina precedente)
|
||||
if (window.history.length > 1) {
|
||||
this.location.back();
|
||||
} else {
|
||||
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add global horizontal scroll support for wheel on horizontal containers
|
||||
window.addEventListener('wheel', (event: WheelEvent) => {
|
||||
if (Math.abs(event.deltaY) > 0 && Math.abs(event.deltaX) === 0) {
|
||||
@@ -95,6 +294,15 @@ export class AppComponent implements OnInit {
|
||||
// Set signal indicating startup version check is complete
|
||||
this.settingsService.isVersionCheckComplete.set(true);
|
||||
|
||||
// Handle PWA Launch Queue if supported (focus-existing launch behavior)
|
||||
if ('launchQueue' in window) {
|
||||
(window as any).launchQueue.setConsumer((launchParams: any) => {
|
||||
if (launchParams.targetURL) {
|
||||
this.handleLaunchUrl(launchParams.targetURL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.route.queryParams.subscribe(params => {
|
||||
const protocolUrl = params['url'];
|
||||
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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> {
|
||||
// Controlla SEMPRE version.json per primo — è il modo più affidabile per
|
||||
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
|
||||
// Su mobile, checkForUpdate() può essere lento o inaffidabile.
|
||||
// Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
|
||||
// Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
|
||||
try {
|
||||
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
|
||||
const response = await Promise.race([
|
||||
@@ -137,10 +454,9 @@ export class AppComponent implements OnInit {
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.version && data.version !== VERSION) {
|
||||
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Forza aggiornamento...`);
|
||||
const overlay = showFullscreenUpdateOverlay();
|
||||
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Avvio aggiornamento automatico...`);
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
const hasSwUpdate = await Promise.race([
|
||||
@@ -148,7 +464,7 @@ export class AppComponent implements OnInit {
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
||||
]);
|
||||
if (hasSwUpdate) {
|
||||
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...');
|
||||
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...');
|
||||
await Promise.race([
|
||||
this.swUpdate.activateUpdate(),
|
||||
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) {
|
||||
try {
|
||||
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) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
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) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
@@ -185,104 +501,34 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
overlay.finish();
|
||||
|
||||
// Ricarica con parametro cache-busting per forzare l'allineamento remoto
|
||||
// Ricarica la pagina in modo trasparente
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('update_cb', Date.now().toString());
|
||||
window.location.replace(url.toString());
|
||||
return true;
|
||||
} else {
|
||||
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PWA-Update] version.json check fallito:', e);
|
||||
}
|
||||
|
||||
// 2. Fallback: Prova SwUpdate nel caso in cui il controllo version.json sia fallito o sia stato servito dalla cache
|
||||
if (this.swUpdate.isEnabled) {
|
||||
try {
|
||||
console.log('[PWA-Update] Verifica aggiornamenti via SwUpdate all\'avvio...');
|
||||
|
||||
let activated = false;
|
||||
let overlay: any = null;
|
||||
|
||||
const activateAndReload = async () => {
|
||||
if (activated) return;
|
||||
activated = true;
|
||||
try {
|
||||
await this.swUpdate.activateUpdate();
|
||||
} catch (e) {
|
||||
console.warn('[PWA-Update] activateUpdate fallito all\'avvio:', e);
|
||||
}
|
||||
|
||||
// Deregistra i vecchi SW e cancella le cache per un ricaricamento pulito
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const reg of registrations) {
|
||||
await reg.unregister();
|
||||
}
|
||||
}
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (overlay) overlay.finish();
|
||||
setTimeout(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('update_cb', Date.now().toString());
|
||||
window.location.replace(url.toString());
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// Sottoscrivi PRIMA di verificare l'aggiornamento per evitare race condition
|
||||
const sub = this.swUpdate.versionUpdates
|
||||
.pipe(
|
||||
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
|
||||
first()
|
||||
)
|
||||
.subscribe(() => {
|
||||
console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio');
|
||||
activateAndReload();
|
||||
});
|
||||
|
||||
// Concedi fino a 8 secondi al controllo SW — le connessioni mobili possono essere lente
|
||||
const hasUpdate = await Promise.race([
|
||||
this.swUpdate.checkForUpdate(),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
||||
]);
|
||||
|
||||
if (hasUpdate) {
|
||||
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...');
|
||||
overlay = showFullscreenUpdateOverlay();
|
||||
|
||||
// Timeout di sicurezza di 25 secondi: se VERSION_READY non arriva, attiva comunque
|
||||
setTimeout(() => {
|
||||
console.log('[PWA-Update] Safety timeout raggiunto all\'avvio, procedo...');
|
||||
sub.unsubscribe();
|
||||
activateAndReload();
|
||||
}, 25000);
|
||||
|
||||
return true; // Attendi il ricaricamento
|
||||
} else {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[PWA-Update] Controllo SwUpdate fallito all\'avvio:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async checkAndRedirectToPwa() {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
console.log('[AppComponent] Running on localhost - skipping PWA redirect and install overlays.');
|
||||
this.checkLoaderDismissal();
|
||||
return;
|
||||
}
|
||||
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
|
||||
if (isStandalone) {
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
this.checkLoaderDismissal();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -290,23 +536,33 @@ export class AppComponent implements OnInit {
|
||||
const path = window.location.pathname;
|
||||
this.protocolLink = `web+canti://open${path}${search}`;
|
||||
|
||||
// Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo
|
||||
let isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
||||
if (!isInstalled && 'getInstalledRelatedApps' in navigator) {
|
||||
let isInstalled = false;
|
||||
if ('getInstalledRelatedApps' in navigator) {
|
||||
try {
|
||||
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
||||
isInstalled = relatedApps.length > 0;
|
||||
if (isInstalled) {
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to check installed apps:', e);
|
||||
}
|
||||
} 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) {
|
||||
const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true';
|
||||
if (!skipRedirect) {
|
||||
if (sessionStorage.getItem('skip-pwa-redirect') === 'true') {
|
||||
this.showRedirectOverlay.set(false);
|
||||
this.checkLoaderDismissal();
|
||||
return;
|
||||
}
|
||||
this.showRedirectOverlay.set(true);
|
||||
this.redirectFailed.set(false);
|
||||
if ((window as any).PwaLoader) {
|
||||
@@ -316,18 +572,16 @@ export class AppComponent implements OnInit {
|
||||
setTimeout(() => {
|
||||
window.location.href = this.protocolLink;
|
||||
|
||||
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
|
||||
// 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);
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.hide();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}, 800);
|
||||
}
|
||||
} else {
|
||||
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
|
||||
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
|
||||
@@ -335,6 +589,8 @@ export class AppComponent implements OnInit {
|
||||
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
||||
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
|
||||
this.showInstallOverlay.set(true);
|
||||
} else {
|
||||
this.checkLoaderDismissal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,22 +616,94 @@ export class AppComponent implements OnInit {
|
||||
stayInBrowser() {
|
||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||
this.showRedirectOverlay.set(false);
|
||||
this.checkLoaderDismissal();
|
||||
}
|
||||
|
||||
stayInBrowserForceUninstallCheck() {
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
this.isPwaInstalled.set(false);
|
||||
this.stayInBrowser();
|
||||
}
|
||||
|
||||
closeInstallOverlay() {
|
||||
sessionStorage.setItem('skip-pwa-install', 'true');
|
||||
this.showInstallOverlay.set(false);
|
||||
this.checkLoaderDismissal();
|
||||
}
|
||||
|
||||
async triggerInstall() {
|
||||
await this.settingsService.installPwa();
|
||||
this.closeInstallOverlay();
|
||||
this.isInstalling.set(true);
|
||||
this.showInstallOverlay.set(false);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Installazione in corso...',
|
||||
desc: 'Completa l\'installazione tramite la finestra del browser.'
|
||||
});
|
||||
}
|
||||
|
||||
const outcome = await this.settingsService.installPwa();
|
||||
if (outcome === 'accepted') {
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(true);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Apertura Applicazione...',
|
||||
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
|
||||
isRedirect: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(false);
|
||||
this.showInstallOverlay.set(true);
|
||||
this.checkLoaderDismissal();
|
||||
}
|
||||
}
|
||||
|
||||
async triggerInstallFromRedirect() {
|
||||
await this.settingsService.installPwa();
|
||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||
this.isInstalling.set(true);
|
||||
this.showRedirectOverlay.set(false);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Installazione in corso...',
|
||||
desc: 'Completa l\'installazione tramite la finestra del browser.'
|
||||
});
|
||||
}
|
||||
|
||||
const outcome = await this.settingsService.installPwa();
|
||||
if (outcome === 'accepted') {
|
||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(true);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Apertura Applicazione...',
|
||||
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
|
||||
isRedirect: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(false);
|
||||
this.showRedirectOverlay.set(true);
|
||||
this.checkLoaderDismissal();
|
||||
}
|
||||
}
|
||||
|
||||
private exitApp() {
|
||||
try {
|
||||
App.exitApp();
|
||||
} catch (e) {}
|
||||
try {
|
||||
if ((navigator as any)?.app?.exitApp) {
|
||||
(navigator as any).app.exitApp();
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
window.close();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,3 +753,4 @@ export function showFullscreenUpdateOverlay() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
<zxing-scanner
|
||||
[formats]="allowedFormats"
|
||||
[device]="currentDevice"
|
||||
[autostart]="true"
|
||||
[tryHarder]="false"
|
||||
(camerasFound)="onCamerasFound($event)"
|
||||
(scanSuccess)="onCodeResult($event)">
|
||||
(scanSuccess)="onCodeResult($event)"
|
||||
(scanError)="onScanError($event)"
|
||||
(scanFailure)="onScanFailure($event)">
|
||||
</zxing-scanner>
|
||||
|
||||
<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() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
+350
-123
@@ -5,20 +5,26 @@
|
||||
<img src="assets/icon/favicon.png" class="header-logo">
|
||||
<div class="header-text-group">
|
||||
<span class="app-name">{{ appName }}</span>
|
||||
<span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
|
||||
v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span>
|
||||
<span *ngIf="hasUpdateAvailable()" style="display: inline-flex; align-items: center; justify-content: center; padding: 4px; margin-left: 2px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 24px; height: 24px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3);">
|
||||
<ion-icon name="refresh-outline" style="font-size: 1.15rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
|
||||
</span>
|
||||
<span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
|
||||
v{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
|
||||
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto" *ngIf="settingsService.showEditor()">
|
||||
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
|
||||
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
|
||||
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
<ng-template #showQrBtn>
|
||||
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ng-template>
|
||||
<ion-button routerLink="/settings" class="settings-btn">
|
||||
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -32,32 +38,134 @@
|
||||
placeholder="Cerca un canto..."
|
||||
[value]="searchQuery()"
|
||||
(ionInput)="onSearch($event)"
|
||||
[disabled]="isAdvancedSearchOpen()"
|
||||
class="custom-searchbar">
|
||||
</ion-searchbar>
|
||||
<ion-button fill="clear" (click)="toggleVoiceSearch()" class="voice-search-btn">
|
||||
<ion-icon slot="icon-only" [name]="audioEngine.isSearching() ? 'mic' : 'mic-outline'" [color]="audioEngine.isSearching() ? 'danger' : 'secondary'"></ion-icon>
|
||||
<ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
|
||||
<ion-icon slot="icon-only" name="options-outline" [color]="isAdvancedSearchOpen() ? 'secondary' : 'medium'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="toggleVoiceSearch('global')" class="voice-search-btn" [disabled]="isAdvancedSearchOpen()">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions-row">
|
||||
<!-- Advanced Search & Filters Toggle Row -->
|
||||
<div class="advanced-search-toggle-row">
|
||||
<div class="toggle-row-left">
|
||||
<div class="song-count-card outfit-font">
|
||||
{{ filteredCanti().length }}
|
||||
</div>
|
||||
<div class="filter-buttons">
|
||||
<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>
|
||||
<button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
|
||||
<ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
|
||||
<span>Ricerche</span>
|
||||
<span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
</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>
|
||||
|
||||
<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 Panel -->
|
||||
<div class="advanced-search-panel glass outfit-font"
|
||||
*ngIf="isAdvancedSearchOpen()"
|
||||
(touchstart)="onPanelTouchStart($event)"
|
||||
(touchend)="onPanelTouchEnd($event, 'advancedSearch')">
|
||||
<div class="adv-search-grid">
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per titolo</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="text-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titolo canto..."
|
||||
[value]="searchTitle()"
|
||||
(input)="onSearchTitleInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchTitle()" (click)="searchTitle.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('title')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per autore</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome autore..."
|
||||
[value]="searchAuthor()"
|
||||
(input)="onSearchAuthorInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchAuthor()" (click)="searchAuthor.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('author')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per testo</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Parole nel testo..."
|
||||
[value]="searchText()"
|
||||
(input)="onSearchTextInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchText()" (click)="searchText.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('text')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per nr canto</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Numero canto..."
|
||||
[value]="searchNumber()"
|
||||
(input)="onSearchNumberInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchNumber()" (click)="searchNumber.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('number')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Card (default closed) -->
|
||||
<div class="filter-card glass outfit-font"
|
||||
*ngIf="isPlaylistCardOpen()"
|
||||
(touchstart)="onPanelTouchStart($event)"
|
||||
(touchend)="onPanelTouchEnd($event, 'playlistCard')">
|
||||
<div class="filter-card-row">
|
||||
<span class="filter-row-label">Playlist</span>
|
||||
<div class="filter-row-items">
|
||||
<!-- Comunità filter button if enabled -->
|
||||
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
||||
<ion-button
|
||||
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
@@ -70,42 +178,108 @@
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('lista_completa')"
|
||||
class="filter-chip">
|
||||
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
<!-- 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>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'liturgico' || selectedLiturgico() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('liturgico')"
|
||||
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 class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'tematico' || selectedTematico() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('tematico')"
|
||||
class="filter-chip">
|
||||
{{ selectedTematico() !== null ? '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 *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
|
||||
Nessuna playlist creata
|
||||
</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"
|
||||
@@ -127,18 +301,69 @@
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showOnlyMine() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleOnlyMine()"
|
||||
class="filter-chip">
|
||||
Miei
|
||||
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
|
||||
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
|
||||
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
|
||||
<div class="playlist-actions-bar">
|
||||
<!-- Duration Badge on the left -->
|
||||
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>{{ totalPlaylistDuration() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Name in middle -->
|
||||
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
|
||||
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Active Playlist Actions -->
|
||||
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
|
||||
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
|
||||
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
|
||||
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
|
||||
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
|
||||
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
|
||||
<span class="selection-count">{{ reorderList().length }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="playlistService.activeListName() || ''"
|
||||
(input)="playlistService.activeListName.set($any($event.target).value)"
|
||||
placeholder="Nome playlist..."
|
||||
class="playlist-name-input outfit-font"
|
||||
/>
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn" *ngIf="reorderList().length > 0">
|
||||
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -149,62 +374,6 @@
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</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>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -221,6 +390,7 @@
|
||||
<div class="loading-wrapper">
|
||||
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
||||
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
|
||||
<ion-progress-bar [value]="cantiService.progress() / 100" color="secondary" style="width: 200px; border-radius: 10px; height: 8px; margin-top: 8px;"></ion-progress-bar>
|
||||
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,6 +471,23 @@
|
||||
</h2>
|
||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
||||
</p>
|
||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px;"
|
||||
*ngIf="(settingsService.showDurationBpmTonality() && (canto.durata || canto.bpm || getSongTonality(canto))) || (settingsService.showUpdateDate() && canto.data_update) || (showTopTen() && getEsecuzioniCount(canto.id) !== null) || (showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null)">
|
||||
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||
<span *ngIf="canto.durata" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
|
||||
<ion-icon name="time-outline" style="font-size: 0.75rem;"></ion-icon>
|
||||
{{ canto.durata }}
|
||||
</span>
|
||||
<span *ngIf="canto.bpm" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
|
||||
<ion-icon name="pulse-outline" style="font-size: 0.75rem;"></ion-icon>
|
||||
{{ canto.bpm }} BPM
|
||||
</span>
|
||||
<span *ngIf="getSongTonality(canto)" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
|
||||
<ion-icon name="musical-note-outline" style="font-size: 0.75rem;"></ion-icon>
|
||||
{{ getSongTonality(canto) }}
|
||||
</span>
|
||||
</ng-container>
|
||||
<span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;">
|
||||
<ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon>
|
||||
agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
|
||||
@@ -352,8 +539,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (only for local songs) -->
|
||||
<div *ngIf="canto.id.startsWith('my_')" class="delete-section" (click)="$event.stopPropagation()">
|
||||
<!-- Delete Button (only for "mio" songs when "Miei" filter is active) -->
|
||||
<div *ngIf="settingsService.showEditor() && canto.id.startsWith('my_') && showOnlyMine()" class="delete-section" (click)="$event.stopPropagation()">
|
||||
<ion-button fill="clear" color="danger" (click)="deleteMyCanto(canto.id, $event)">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -379,25 +566,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
|
||||
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="(comunitaService.isFilterActive() && comunitaService.comunitaCode()) || limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
|
||||
<ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
|
||||
</ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
|
||||
</ion-content>
|
||||
|
||||
<ion-footer *ngIf="youtubePlayerService.isPlayerSupported() && youtubePlayerService.currentCantoId()" class="ion-no-border">
|
||||
<ion-footer *ngIf="youtubePlayerService.currentCantoId()" class="ion-no-border">
|
||||
<ion-toolbar class="global-player-toolbar glass">
|
||||
<div class="player-content">
|
||||
|
||||
<div class="controls-row">
|
||||
<ion-button fill="clear" color="secondary" (click)="playPrevPreview($event)" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
|
||||
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<ion-button fill="clear" color="secondary" (click)="youtubePlayerService.isPlaying() ? stopVideo($event) : playVideo($event, youtubePlayerService.currentCantoId()!)" class="play-btn">
|
||||
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && getPlayingCanto()?.link_youtube && getPlayingCanto()?.link_youtube!.length > 5; else noAudioMsg">
|
||||
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn">
|
||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||
|
||||
<ion-range
|
||||
[min]="0"
|
||||
[max]="youtubePlayerService.videoDuration()"
|
||||
@@ -406,19 +597,55 @@
|
||||
(ionKnobMoveStart)="onSeekStart()"
|
||||
(ionKnobMoveEnd)="onSeekEnd()"
|
||||
color="secondary"
|
||||
class="global-range">
|
||||
class="global-range"
|
||||
style="margin: 0; padding: 0 4px;">
|
||||
</ion-range>
|
||||
|
||||
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
||||
</ion-button>
|
||||
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||
</ng-container>
|
||||
<ng-template #noAudioMsg>
|
||||
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
|
||||
Audio non disponibile
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ion-button fill="clear" color="medium" (click)="stopVideo($event)" class="close-btn">
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
+590
-56
@@ -48,32 +48,327 @@
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.voice-search-btn {
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
.adv-search-btn, .voice-search-btn {
|
||||
--padding-start: 6px;
|
||||
--padding-end: 6px;
|
||||
margin: 0;
|
||||
height: 44px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-search-btn.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-search-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.toggle-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.song-count-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 8px;
|
||||
border-radius: 12px;
|
||||
height: 30px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.advanced-search-link, .clear-advanced-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--ion-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
padding: 4px 5px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover, &:active {
|
||||
opacity: 1;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.12);
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inline-clear-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-danger, #ff4961);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clear-advanced-link {
|
||||
color: var(--ion-color-danger, #ff4961);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover, &:active {
|
||||
background: rgba(255, 73, 97, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions-row {
|
||||
.advanced-search-panel {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
backdrop-filter: blur(12px);
|
||||
animation: advSearchFadeIn 0.25s ease-out;
|
||||
|
||||
.adv-search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.adv-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.adv-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.adv-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
gap: 12px;
|
||||
padding: 8px 4px 8px 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 0 10px;
|
||||
height: 38px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
// Hide scrollbar but keep functionality
|
||||
&:focus-within {
|
||||
border-color: var(--ion-color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.adv-input-icon {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.adv-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.adv-clear-icon {
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-mic-btn {
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
margin: 0 0 0 2px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes advSearchFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.advanced-search-link {
|
||||
color: var(--ion-color-secondary) !important;
|
||||
}
|
||||
|
||||
.clear-advanced-link {
|
||||
color: var(--ion-color-danger, #d32f2f) !important;
|
||||
}
|
||||
|
||||
.advanced-search-panel {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
|
||||
.adv-input-group {
|
||||
.adv-label {
|
||||
color: var(--ion-color-secondary, #d96b00) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.adv-input-wrapper {
|
||||
background: #f4f5f8 !important;
|
||||
border: 1px solid #c0c4cc !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--ion-color-secondary, #d96b00) !important;
|
||||
box-shadow: 0 0 0 2px rgba(217, 107, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.adv-input-icon {
|
||||
color: var(--ion-color-secondary, #d96b00) !important;
|
||||
}
|
||||
|
||||
.adv-input {
|
||||
color: #000000 !important;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #666666 !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-clear-icon {
|
||||
color: #666666 !important;
|
||||
|
||||
&:hover {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(12px);
|
||||
margin-top: 4px;
|
||||
|
||||
.filter-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
|
||||
.filter-row-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-color-secondary);
|
||||
width: 68px;
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-row-items {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
.empty-playlist-text {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
.song-count-card {
|
||||
display: flex;
|
||||
@@ -84,18 +379,17 @@
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
height: 32px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-button.filter-chip {
|
||||
--border-radius: 20px;
|
||||
@@ -116,27 +410,85 @@
|
||||
padding: 4px;
|
||||
margin-right: -8px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
ion-toolbar.playlist-toolbar {
|
||||
--padding-top: 0px;
|
||||
--padding-bottom: 8px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--min-height: auto;
|
||||
}
|
||||
|
||||
.playlist-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
.playlist-duration-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 10px;
|
||||
border-radius: 12px;
|
||||
height: 30px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
margin: 0 8px;
|
||||
flex-shrink: 1;
|
||||
|
||||
.playlist-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-pill {
|
||||
margin: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.transparent-list {
|
||||
background: transparent !important;
|
||||
padding-bottom: 120px; // Spazio per il player fisso
|
||||
@@ -148,10 +500,9 @@ ion-item.glass {
|
||||
--padding-start: 16px;
|
||||
--inner-padding-end: 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
--background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -161,28 +512,87 @@ ion-title {
|
||||
letter-spacing: 1px;
|
||||
color: var(--ion-color-secondary);
|
||||
padding-inline: 0;
|
||||
|
||||
.add-btn, .settings-btn {
|
||||
--color: var(--ion-color-secondary);
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
ion-header {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
ion-buttons[slot="end"] {
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.update-btn, .add-btn, .settings-btn {
|
||||
--color: var(--ion-color-secondary);
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin-left: -4px;
|
||||
margin-right: 0;
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
animation: pulse-update 2.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-update {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 6px rgba(var(--ion-color-secondary-rgb), 0.6));
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 375px) {
|
||||
ion-buttons[slot="end"] {
|
||||
gap: 0px;
|
||||
}
|
||||
.update-btn, .add-btn, .settings-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin-left: -5px;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
.settings-btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
ion-title {
|
||||
.header-logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room
|
||||
gap: 8px;
|
||||
padding: 10px 0 10px 8px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-text-group {
|
||||
@@ -193,24 +603,26 @@ ion-title {
|
||||
line-height: 1;
|
||||
|
||||
.app-name {
|
||||
font-size: 1.4rem;
|
||||
font-size: clamp(1.1rem, 3.8vw, 1.25rem);
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.settings-btn {
|
||||
margin-right: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.special-list-banner {
|
||||
@@ -384,6 +796,12 @@ ion-title {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
.playlist-name-input {
|
||||
color: #000000 !important;
|
||||
&::placeholder {
|
||||
color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Item Layout
|
||||
@@ -426,7 +844,8 @@ ion-title {
|
||||
width: 100%;
|
||||
|
||||
.selection-column {
|
||||
padding: 10px 14px 10px 0;
|
||||
padding: 12px 18px 12px 12px;
|
||||
margin-left: -12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -443,6 +862,11 @@ ion-title {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
display: flex;
|
||||
@@ -671,6 +1095,30 @@ ion-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-name-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
margin: 0 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8rem;
|
||||
width: 120px;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--ion-color-secondary);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 0 6px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.small-action-btn {
|
||||
@@ -725,7 +1173,6 @@ ion-title {
|
||||
&.glass {
|
||||
background: rgba(26, 26, 46, 0.95) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@@ -741,25 +1188,14 @@ ion-title {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.play-btn {
|
||||
.play-btn, .skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+640
-49
@@ -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 { CantiService } from '../services/canti.service';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
@@ -19,6 +19,7 @@ import { environment } from '../../environments/environment';
|
||||
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
import { showFullscreenUpdateOverlay } from '../app.component';
|
||||
import { LyricsParserService } from '../services/lyrics-parser.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -27,7 +28,41 @@ import { showFullscreenUpdateOverlay } from '../app.component';
|
||||
standalone: false,
|
||||
})
|
||||
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 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 selectedTematico = signal<number | null>(null);
|
||||
public showOnlyMine = signal<boolean>(false);
|
||||
@@ -35,6 +70,25 @@ export class HomePage implements OnDestroy {
|
||||
public showSuggeriti = signal<boolean>(false);
|
||||
public showValidati = 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 activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
|
||||
public loadedThumbs = new Set<string>();
|
||||
@@ -45,6 +99,13 @@ export class HomePage implements OnDestroy {
|
||||
public limit = signal<number>(30);
|
||||
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 youtubePlayerService = inject(YoutubePlayerService);
|
||||
public comunitaService = inject(ComunitaService);
|
||||
@@ -64,6 +125,7 @@ export class HomePage implements OnDestroy {
|
||||
public playlistService = inject(PlaylistService);
|
||||
public settingsService = inject(SettingsService);
|
||||
public cantiLettureService = inject(CantiLettureService);
|
||||
public lyricsParser = inject(LyricsParserService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private modalCtrl = inject(ModalController);
|
||||
@@ -174,6 +236,24 @@ export class HomePage implements OnDestroy {
|
||||
} catch (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();
|
||||
setTimeout(() => {
|
||||
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
|
||||
@@ -208,17 +288,12 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
private async checkUpdateStatus() {
|
||||
try {
|
||||
let swFoundUpdate = false;
|
||||
if (this.swUpdate.isEnabled) {
|
||||
const swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
|
||||
if (swFoundUpdate) {
|
||||
this.hasUpdateAvailable.set(true);
|
||||
return;
|
||||
}
|
||||
swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
|
||||
}
|
||||
const mismatch = await this.checkVersionJson();
|
||||
if (mismatch) {
|
||||
this.hasUpdateAvailable.set(true);
|
||||
}
|
||||
this.hasUpdateAvailable.set(swFoundUpdate || mismatch);
|
||||
} catch (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)
|
||||
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)
|
||||
@@ -384,36 +459,85 @@ export class HomePage implements OnDestroy {
|
||||
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
||||
});
|
||||
} else {
|
||||
const tokens = query.split(/\s+/).filter(t => t.length > 0);
|
||||
|
||||
list = list.filter(c => {
|
||||
const titleMatch = this.normalize(c.titolo).includes(query);
|
||||
const authorMatch = this.normalize(c.autore).includes(query);
|
||||
const normTitle = this.normalize(c.titolo);
|
||||
const normAuthor = this.normalize(c.autore);
|
||||
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 || '')
|
||||
.replace(/{.*?}/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
|
||||
if (topTen) {
|
||||
const eseguiti = this.cantiService.cantiEseguiti();
|
||||
const eseguitiMap = new Map<number, number>();
|
||||
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
||||
|
||||
list = list.sort((a, b) => {
|
||||
const numA = eseguitiMap.get(a.id_canti) || 0;
|
||||
const numB = eseguitiMap.get(b.id_canti) || 0;
|
||||
list = [...list].sort((a, b) => {
|
||||
const numA = this.getEsecuzioniCount(a.id) || 0;
|
||||
const numB = this.getEsecuzioniCount(b.id) || 0;
|
||||
return numB - numA;
|
||||
});
|
||||
} else if (suggeriti) {
|
||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||
list = list.sort((a, b) => {
|
||||
list = [...list].sort((a, b) => {
|
||||
const pesoA = suggMap.get(a.id_canti) || 0;
|
||||
const pesoB = suggMap.get(b.id_canti) || 0;
|
||||
return pesoB - pesoA;
|
||||
@@ -427,9 +551,69 @@ export class HomePage implements OnDestroy {
|
||||
if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
|
||||
return this.filteredCanti();
|
||||
}
|
||||
if (this.comunitaService.isFilterActive() && this.comunitaService.comunitaCode()) {
|
||||
return this.filteredCanti();
|
||||
}
|
||||
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() {
|
||||
// Check if there is an update available
|
||||
this.checkUpdateStatus();
|
||||
@@ -444,8 +628,15 @@ export class HomePage implements OnDestroy {
|
||||
// Polling setup: check for updates every 30 seconds
|
||||
this.updatePollInterval = setInterval(() => {
|
||||
this.checkUpdateStatus();
|
||||
if (this.isRemotePlaylist()) {
|
||||
this.playlistService.checkForRemotePlaylistUpdates();
|
||||
}
|
||||
}, 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
|
||||
@@ -477,14 +668,33 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
// Sync speech recognition results to search query
|
||||
// Sync speech recognition results to search query or advanced field
|
||||
effect(() => {
|
||||
const transcript = this.audioEngine.searchTranscript();
|
||||
if (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;
|
||||
effect(() => {
|
||||
if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) {
|
||||
@@ -526,9 +736,6 @@ export class HomePage implements OnDestroy {
|
||||
hasAutoActivatedPlaylist = true;
|
||||
this.selectPlaylist(lastPl);
|
||||
this.activeFilterType.set('playlist');
|
||||
if (lastPl.ids && lastPl.ids.length > 0) {
|
||||
this.goToCanto(lastPl.ids[0]);
|
||||
}
|
||||
} else {
|
||||
hasAutoActivatedPlaylist = true;
|
||||
}
|
||||
@@ -589,6 +796,25 @@ export class HomePage implements OnDestroy {
|
||||
this.isAddingSongs.set(false);
|
||||
}
|
||||
}, { 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)) {
|
||||
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
|
||||
this.activeFilterType.set('playlist');
|
||||
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) {
|
||||
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),
|
||||
link_youtube: item.link_youtube || '',
|
||||
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);
|
||||
@@ -803,7 +1027,9 @@ export class HomePage implements OnDestroy {
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
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
|
||||
@@ -848,6 +1074,7 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
// Select it immediately
|
||||
this.selectPlaylist(selectedPl);
|
||||
this.playlistService.lastPlaylist.set(selectedPl);
|
||||
this.activeFilterType.set('playlist');
|
||||
|
||||
await loading.dismiss();
|
||||
@@ -860,11 +1087,7 @@ export class HomePage implements OnDestroy {
|
||||
await toast.present();
|
||||
|
||||
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 });
|
||||
if (openFirst) {
|
||||
this.goToCanto(selectedPl.ids[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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),
|
||||
autore: item.autore || '',
|
||||
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) {
|
||||
@@ -1004,6 +1229,95 @@ export class HomePage implements OnDestroy {
|
||||
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) {
|
||||
event.stopPropagation();
|
||||
const alert = await this.alertCtrl.create({
|
||||
@@ -1163,7 +1477,8 @@ export class HomePage implements OnDestroy {
|
||||
this.showValidati() ||
|
||||
this.showNonValidati() ||
|
||||
this.playlistService.activeListName() !== null ||
|
||||
this.searchQuery() !== '';
|
||||
this.searchQuery() !== '' ||
|
||||
this.hasAdvancedSearchParams();
|
||||
}
|
||||
|
||||
shouldShowSubSectionToolbar(): boolean {
|
||||
@@ -1189,6 +1504,7 @@ export class HomePage implements OnDestroy {
|
||||
this.playlistService.activeListName.set(null);
|
||||
this.playlistService.activePlaylistId.set(null);
|
||||
this.searchQuery.set('');
|
||||
this.clearAdvancedSearch();
|
||||
this.activeFilterType.set(null);
|
||||
this.limit.set(10);
|
||||
}
|
||||
@@ -1219,6 +1535,11 @@ export class HomePage implements OnDestroy {
|
||||
this.playlistService.activeListIds.set(pl.ids);
|
||||
this.playlistService.activeListName.set(pl.name);
|
||||
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
|
||||
this.limit.set(50);
|
||||
}
|
||||
@@ -1356,6 +1677,7 @@ export class HomePage implements OnDestroy {
|
||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||
return;
|
||||
}
|
||||
this.isBlackOverlayDismissed.set(false);
|
||||
this.youtubePlayerService.setMediaSessionCallbacks(
|
||||
() => this.playNextPreview(),
|
||||
() => this.playPrevPreview()
|
||||
@@ -1386,6 +1708,19 @@ export class HomePage implements OnDestroy {
|
||||
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) {
|
||||
if (event) event.stopPropagation();
|
||||
const currentId = this.youtubePlayerService.currentCantoId();
|
||||
@@ -1605,18 +1940,21 @@ export class HomePage implements OnDestroy {
|
||||
// Automatically save to the device!
|
||||
this.playlistService.savePlaylist(playlistName, ids);
|
||||
|
||||
// Activate the playlist filter and open the first song!
|
||||
// Activate the playlist filter
|
||||
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()) {
|
||||
const currentTarget = this.activeVoiceField();
|
||||
this.audioEngine.stopSearchRecognition();
|
||||
if (currentTarget !== target) {
|
||||
this.activeVoiceField.set(target);
|
||||
this.audioEngine.startSearchRecognition();
|
||||
}
|
||||
} else {
|
||||
this.activeVoiceField.set(target);
|
||||
this.audioEngine.startSearchRecognition();
|
||||
}
|
||||
}
|
||||
@@ -1647,6 +1985,9 @@ export class HomePage implements OnDestroy {
|
||||
const songSettings = pl ? pl.songSettings : undefined;
|
||||
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({
|
||||
message: 'Playlist salvata!',
|
||||
duration: 2000,
|
||||
@@ -1684,18 +2025,57 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
isComunitaPlaylist(): boolean {
|
||||
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 {
|
||||
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 {
|
||||
const id = this.playlistService.activePlaylistId();
|
||||
if (!id) return false;
|
||||
return this.playlistService.playlists().some(p => p.id === id);
|
||||
if (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) {
|
||||
@@ -1725,6 +2105,105 @@ export class HomePage implements OnDestroy {
|
||||
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() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: QrScannerComponent
|
||||
@@ -1835,6 +2314,44 @@ export class HomePage implements OnDestroy {
|
||||
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() {
|
||||
if (this.comunitaService.comunitaCode()) {
|
||||
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) {
|
||||
const list = this.cantiLettureService.availableMasses();
|
||||
if (list.length === 0) return;
|
||||
@@ -1926,5 +2473,49 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
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;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
|
||||
.active-line .chord {
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
<ion-header [translucent]="true" class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient top-toolbar">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title class="outfit-font wrapped-title">
|
||||
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti">
|
||||
<ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
|
||||
<div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
|
||||
|
||||
<!-- Row 1: Number + Title + Settings -->
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
|
||||
[style.fontSize.rem]="0.85 * fontSize()"
|
||||
[style.width.px]="32 * fontSize()"
|
||||
[style.height.px]="32 * fontSize()"
|
||||
[style.lineHeight.px]="28 * fontSize()"
|
||||
[style.borderRadius.px]="8 * fontSize()">
|
||||
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
|
||||
</span>
|
||||
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||
<span class="canto-title"
|
||||
[style.fontSize.rem]="1.15 * fontSize()"
|
||||
style="font-weight: 700; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
|
||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||
<span>{{ canto()?.titolo || 'Player' }}</span>
|
||||
</span>
|
||||
<ion-button routerLink="/settings" class="settings-btn" fill="clear" style="flex-shrink: 0; margin: 0; --color: var(--ion-color-secondary);">
|
||||
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Author -->
|
||||
<div *ngIf="!isLandscapeActive()"
|
||||
style="display: block; width: 100%; margin-top: 1px; margin-bottom: 2px;"
|
||||
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||
<span class="song-author"
|
||||
[style.fontSize.rem]="0.95 * fontSize()"
|
||||
style="color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
|
||||
{{ canto()?.autore || 'Autore sconosciuto' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Badges only -->
|
||||
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
|
||||
style="margin-top: 2px; width: 100%;"
|
||||
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||
<div style="display: flex; align-items: center; gap: inherit;">
|
||||
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||
<span *ngIf="canto()?.durata" class="song-duration-badge"
|
||||
[style.fontSize.rem]="0.75 * fontSize()"
|
||||
[style.height.px]="26 * fontSize()"
|
||||
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||
{{ canto()?.durata }}
|
||||
</span>
|
||||
<span *ngIf="canto()?.bpm" class="song-bpm-badge"
|
||||
[style.fontSize.rem]="0.75 * fontSize()"
|
||||
[style.height.px]="26 * fontSize()"
|
||||
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="pulse-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||
{{ canto()?.bpm }} BPM
|
||||
</span>
|
||||
<span *ngIf="tonality()" class="song-tonality-badge"
|
||||
[style.fontSize.rem]="0.75 * fontSize()"
|
||||
[style.height.px]="26 * fontSize()"
|
||||
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="musical-note-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||
{{ tonality() }}
|
||||
</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
</div>
|
||||
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only"
|
||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||
[color]="showChords() ? 'secondary' : 'medium'"
|
||||
style="font-size: 1.3rem;">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
|
||||
<!-- Audio Toolbar Removed -->
|
||||
@@ -105,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Sections rendering -->
|
||||
<div class="lyrics-view">
|
||||
<div class="lyrics-view" (click)="handleLyricsClick($event)">
|
||||
<div *ngFor="let section of parsedSections(); let si = index"
|
||||
class="section"
|
||||
[class.chorus]="section.type === 'chorus'"
|
||||
@@ -145,6 +178,9 @@
|
||||
<ion-toolbar class="global-player-toolbar glass">
|
||||
<div class="player-content">
|
||||
<div class="controls-row">
|
||||
<ion-button fill="clear" color="secondary" (click)="goHome()" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="home-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
|
||||
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -154,14 +190,19 @@
|
||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||
|
||||
<ion-range
|
||||
[min]="0"
|
||||
[max]="youtubePlayerService.videoDuration()"
|
||||
[value]="youtubePlayerService.videoProgress()"
|
||||
(ionChange)="onSeek($event)"
|
||||
color="secondary"
|
||||
class="global-range">
|
||||
class="global-range"
|
||||
style="margin: 0; padding: 0 4px;">
|
||||
</ion-range>
|
||||
|
||||
<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);">
|
||||
@@ -204,7 +245,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<!-- Navigation (Avanzamento Karaoke e Riparti da inizio) -->
|
||||
<div class="group">
|
||||
<ion-button fill="clear" size="small" (click)="restart()">
|
||||
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
|
||||
@@ -217,6 +258,28 @@
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (edit, condividi, accordi) -->
|
||||
<div class="group">
|
||||
<ion-button fill="clear" size="small" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()">
|
||||
<ion-icon slot="icon-only" name="create-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="deleteMyCanto()" *ngIf="settingsService.showEditor() && canto()?.id?.startsWith('my_')" color="danger">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="shareCanto()">
|
||||
<ion-icon slot="icon-only" name="share-social-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="toggleChords()">
|
||||
<ion-icon slot="icon-only"
|
||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||
[color]="showChords() ? 'secondary' : 'medium'">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="printCanto()" title="Esporta PDF">
|
||||
<ion-icon slot="icon-only" name="print-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Transposition (Only in chords mode) -->
|
||||
<div class="group" *ngIf="showChords()">
|
||||
<ion-button fill="clear" size="small" (click)="transposeDown()">
|
||||
@@ -224,7 +287,8 @@
|
||||
</ion-button>
|
||||
<div class="transpose-indicator">
|
||||
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
||||
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
|
||||
<span class="val" *ngIf="tonality()">{{ tonality() }}</span>
|
||||
<span class="val" *ngIf="transposeAmount() !== 0" style="opacity: 0.8; font-size: 0.75rem; margin-left: 2px;">({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||
@@ -258,7 +322,36 @@
|
||||
</ion-footer>
|
||||
|
||||
<!-- 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) -->
|
||||
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
|
||||
}
|
||||
|
||||
.canto-title {
|
||||
transition: font-size 0.15s ease;
|
||||
&.small-title {
|
||||
font-size: 1.12rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.outfit-font {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
@@ -20,27 +27,23 @@
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.title-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
|
||||
.canto-number {
|
||||
font-size: 0.85em;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
padding: 2px 10px;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.1);
|
||||
color: var(--ion-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--ion-color-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -56,11 +59,142 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
--color: var(--ion-color-secondary);
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.title-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
font-size: 1.15rem; // Fisso: non influenzato dallo zoom del testo
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
|
||||
.song-author {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-weight: 400;
|
||||
display: inline-block;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
margin: 0;
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive scaling based on viewport width
|
||||
@media (max-width: 480px) {
|
||||
gap: 4px;
|
||||
|
||||
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 3px;
|
||||
height: 22px;
|
||||
gap: 2px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
--padding-start: 0px;
|
||||
--padding-end: 0px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
gap: 2px;
|
||||
|
||||
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 1px 2px;
|
||||
height: 20px;
|
||||
gap: 1px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
--padding-start: 0px;
|
||||
--padding-end: 0px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content and Lyrics
|
||||
@@ -105,25 +239,14 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.play-btn {
|
||||
.play-btn, .skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-btn, .close-btn {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +267,7 @@
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.2rem;
|
||||
position: relative;
|
||||
|
||||
&.chorus {
|
||||
@@ -160,21 +283,22 @@
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
min-height: 1.5em;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
letter-spacing: 3px;
|
||||
|
||||
&.active {
|
||||
color: var(--ion-color-secondary);
|
||||
@@ -200,6 +324,7 @@
|
||||
color: var(--ion-color-secondary);
|
||||
height: 1.2em;
|
||||
margin-bottom: -0.2em;
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
|
||||
.seg-text {
|
||||
@@ -730,6 +855,10 @@ ion-content.full-screen-content {
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.title-main .song-author {
|
||||
color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.slim-toolbar {
|
||||
--background: #ffffff !important;
|
||||
background: #ffffff !important;
|
||||
@@ -890,11 +1019,97 @@ ion-content.full-screen-content {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #000000;
|
||||
z-index: 99999;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000000 !important;
|
||||
z-index: 9999999 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
|
||||
.black-screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
gap: 12px;
|
||||
|
||||
.car-mode-icon {
|
||||
font-size: 3.5rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.car-mode-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.car-mode-subtext {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.car-mode-canto-info {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.car-mode-canto-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.car-mode-canto-author {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.car-mode-progress {
|
||||
margin-top: 8px;
|
||||
|
||||
.car-mode-time {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.car-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 15px;
|
||||
|
||||
.car-mode-btn {
|
||||
--color: rgba(255, 255, 255, 0.25);
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
margin: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
&:active {
|
||||
--color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.landscape-playlist-btn {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { FaceDetectorService } from '../../services/face-detector.service';
|
||||
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
public canto = signal<Canto | null>(null);
|
||||
public activeSongId = signal<string | null>(null);
|
||||
public isTitleWrapped = signal<boolean>(false);
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -33,12 +34,39 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
if (event.key === 'ArrowUp' || event.key === 'MediaTrackPrevious') {
|
||||
this.prevSong();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'MediaTrackNext') {
|
||||
this.nextSong();
|
||||
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 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 math = Math;
|
||||
public youtubePlayerService = inject(YoutubePlayerService);
|
||||
@@ -152,7 +191,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
public faceDetector = inject(FaceDetectorService);
|
||||
private meta = inject(Meta);
|
||||
|
||||
public enableCameraNavigation = signal<boolean>(false);
|
||||
public enableCameraNavigation = computed(() => this.settingsService.cameraNavigationActive());
|
||||
|
||||
private songStartTime: number = 0;
|
||||
|
||||
@@ -189,6 +228,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.playlistService.autoPlayPlaylist()) {
|
||||
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) {
|
||||
this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0);
|
||||
this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2);
|
||||
if (playlistSongSetting.zoom !== undefined) {
|
||||
this.fontSize.set(playlistSongSetting.zoom);
|
||||
this.portraitFontSize = playlistSongSetting.zoom;
|
||||
} else {
|
||||
this.fontSize.set(1.0);
|
||||
this.portraitFontSize = 1.0;
|
||||
}
|
||||
const gZoom = this.settingsService.globalZoom();
|
||||
this.fontSize.set(gZoom);
|
||||
this.portraitFontSize = gZoom;
|
||||
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||
const settings = this.comunitaService.comunitaCantiSettings();
|
||||
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.autoscrollSpeed.set(2);
|
||||
}
|
||||
this.fontSize.set(1.0);
|
||||
this.portraitFontSize = 1.0;
|
||||
const gZoom = this.settingsService.globalZoom();
|
||||
this.fontSize.set(gZoom);
|
||||
this.portraitFontSize = gZoom;
|
||||
} else {
|
||||
this.transposeAmount.set(0);
|
||||
this.autoscrollSpeed.set(2);
|
||||
this.fontSize.set(1.0);
|
||||
this.portraitFontSize = 1.0;
|
||||
const gZoom = this.settingsService.globalZoom();
|
||||
this.fontSize.set(gZoom);
|
||||
this.portraitFontSize = gZoom;
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
// Fullscreen control in landscape mode
|
||||
// Fullscreen control in landscape mode or when black screen is active
|
||||
effect(() => {
|
||||
const isLandscape = this.isLandscapeActive();
|
||||
const isBlack = this.isBlackScreen();
|
||||
|
||||
if (isLandscape) {
|
||||
if (isLandscape || isBlack) {
|
||||
this.enterFullscreen();
|
||||
} else {
|
||||
this.exitFullscreen();
|
||||
@@ -340,6 +380,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
onResize(event: any) {
|
||||
this.windowLandscape.set(window.innerWidth > window.innerHeight);
|
||||
this.checkLandscapeZoom();
|
||||
this.checkTitleWrap();
|
||||
}
|
||||
|
||||
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() {
|
||||
this.checkLandscapeZoom();
|
||||
this.checkTitleWrap();
|
||||
if (this.settingsService.cameraNavigationActive()) {
|
||||
this.initCameraTracking();
|
||||
}
|
||||
const gestureX = this.gestureCtrl.create({
|
||||
el: this.el.nativeElement,
|
||||
direction: 'x',
|
||||
@@ -549,6 +608,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.fontSize.set(limited);
|
||||
if (!this.isLandscape()) {
|
||||
this.portraitFontSize = limited;
|
||||
this.settingsService.globalZoom.set(limited);
|
||||
}
|
||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||
}
|
||||
@@ -587,8 +647,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
activePlaylistId,
|
||||
c.id,
|
||||
this.transposeAmount(),
|
||||
this.autoscrollSpeed(),
|
||||
this.fontSize()
|
||||
this.autoscrollSpeed()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -616,6 +675,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.fontSize.set(limited);
|
||||
if (!this.isLandscape()) {
|
||||
this.portraitFontSize = limited;
|
||||
this.settingsService.globalZoom.set(limited);
|
||||
}
|
||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||
this.updatePlaylistSettings();
|
||||
@@ -630,6 +690,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.fontSize.set(target);
|
||||
if (!this.isLandscape()) {
|
||||
this.portraitFontSize = target;
|
||||
this.settingsService.globalZoom.set(target);
|
||||
}
|
||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||
this.updatePlaylistSettings();
|
||||
@@ -658,6 +719,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.youtubePlayerService.seekTo(event.detail.value);
|
||||
}
|
||||
|
||||
goHome() {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.currentLineIndex.set(0);
|
||||
this.youtubePlayerService.seekTo(0);
|
||||
@@ -915,11 +980,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
nextSong() {
|
||||
let list = this.playlistService.activeListIds();
|
||||
if (list.length === 0) {
|
||||
list = this.cantiService.canti().map(c => c.id);
|
||||
private getSequenceList(): string[] {
|
||||
const currentId = this.canto()?.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;
|
||||
if (!currentId) return;
|
||||
@@ -930,17 +1015,14 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.isLandscapeActive()) {
|
||||
this.isBlackScreen.set(true);
|
||||
}
|
||||
this.router.navigate(['/player'], { queryParams: { id: nextId } });
|
||||
this.router.navigate(['/player'], { queryParams: { id: nextId }, replaceUrl: true });
|
||||
} else {
|
||||
this.playlistService.autoPlayPlaylist.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
prevSong() {
|
||||
let list = this.playlistService.activeListIds();
|
||||
if (list.length === 0) {
|
||||
list = this.cantiService.canti().map(c => c.id);
|
||||
}
|
||||
const list = this.getSequenceList();
|
||||
|
||||
const currentId = this.canto()?.id;
|
||||
if (!currentId) return;
|
||||
@@ -951,7 +1033,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.isLandscapeActive()) {
|
||||
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([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { id },
|
||||
queryParamsHandling: 'merge'
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
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() {
|
||||
const c = this.canto();
|
||||
if (!c) return;
|
||||
@@ -1072,8 +1176,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
// Try to fetch YouTube thumbnail first if available
|
||||
if (thumbUrl) {
|
||||
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([
|
||||
fetch(thumbUrl),
|
||||
fetch(proxiedUrl),
|
||||
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
|
||||
]);
|
||||
if (response.ok) {
|
||||
@@ -1083,10 +1189,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
} catch (e) {
|
||||
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If no YouTube thumb or fetch failed, fallback to local canticristiani logo (favicon)
|
||||
if (!fileToShare) {
|
||||
} else {
|
||||
// If no YouTube thumb is available, fallback to local canticristiani logo (favicon)
|
||||
try {
|
||||
const response = await fetch('assets/icon/favicon.png');
|
||||
if (response.ok) {
|
||||
@@ -1106,7 +1210,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||
return;
|
||||
@@ -1234,7 +1370,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
toggleCameraNavigation() {
|
||||
if (this.enableCameraNavigation()) {
|
||||
if (this.settingsService.cameraNavigationActive()) {
|
||||
this.stopCameraNavigation();
|
||||
} else {
|
||||
this.startCameraNavigation();
|
||||
@@ -1242,7 +1378,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
startCameraNavigation() {
|
||||
this.enableCameraNavigation.set(true);
|
||||
this.settingsService.cameraNavigationActive.set(true);
|
||||
this.initCameraTracking();
|
||||
}
|
||||
|
||||
initCameraTracking() {
|
||||
if (!this.settingsService.cameraNavigationActive()) return;
|
||||
|
||||
setTimeout(async () => {
|
||||
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
|
||||
@@ -1256,16 +1397,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.prev(true);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.enableCameraNavigation.set(false);
|
||||
alert('Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.');
|
||||
} catch (e: any) {
|
||||
this.settingsService.cameraNavigationActive.set(false);
|
||||
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);
|
||||
}
|
||||
|
||||
stopCameraNavigation() {
|
||||
this.enableCameraNavigation.set(false);
|
||||
this.settingsService.cameraNavigationActive.set(false);
|
||||
this.faceDetector.stop();
|
||||
}
|
||||
|
||||
@@ -1273,7 +1415,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.exitFullscreen();
|
||||
this.stopAutoscroll();
|
||||
this.logPreviousSongTime();
|
||||
this.stopCameraNavigation();
|
||||
if (this.settingsService.cameraNavigationActive()) {
|
||||
this.faceDetector.pause();
|
||||
} else {
|
||||
this.faceDetector.stop();
|
||||
}
|
||||
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-title class="outfit-font">Gestione Playlist</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="exportPlaylistPdf()" [disabled]="localSongs.length === 0" title="Esporta PDF">
|
||||
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
|
||||
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -14,8 +17,12 @@
|
||||
|
||||
<ion-content class="bg-gradient">
|
||||
<div class="ion-padding">
|
||||
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0">
|
||||
<p>Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
|
||||
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<p style="margin: 0;">Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
|
||||
<div *ngIf="totalDuration" style="background: rgba(var(--ion-color-secondary-rgb), 0.25); border: 1px solid var(--ion-color-secondary); padding: 4px 10px; border-radius: 10px; font-weight: 800; color: var(--ion-color-secondary); display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-family: 'Outfit', sans-serif; white-space: nowrap; margin-left: 12px;">
|
||||
<ion-icon name="time-outline" style="font-size: 1rem;"></ion-icon>
|
||||
{{ totalDuration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
|
||||
|
||||
@@ -51,6 +51,42 @@ export class PlaylistPage {
|
||||
public qrCodeImage: string | null = null;
|
||||
public savedPlaylistName: string | null = null;
|
||||
|
||||
get totalDuration(): string {
|
||||
const songs = this.localSongs;
|
||||
if (songs.length === 0) return '';
|
||||
let totalSeconds = 0;
|
||||
for (const song of songs) {
|
||||
if (song && song.durata) {
|
||||
totalSeconds += this.parseDuration(song.durata);
|
||||
}
|
||||
}
|
||||
if (totalSeconds === 0) return '';
|
||||
return this.formatTotalDuration(totalSeconds);
|
||||
}
|
||||
|
||||
private parseDuration(dur: string): number {
|
||||
if (!dur) return 0;
|
||||
const parts = dur.split(':').map(Number);
|
||||
if (parts.some(isNaN)) return 0;
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
} else if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private formatTotalDuration(seconds: number): string {
|
||||
const hh = Math.floor(seconds / 3600);
|
||||
const mm = Math.floor((seconds % 3600) / 60);
|
||||
const ss = seconds % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
if (hh > 0) {
|
||||
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
return `${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initial copy to allow local reordering
|
||||
this.localSongs = [...this.selectedSongs()];
|
||||
@@ -178,7 +214,10 @@ export class PlaylistPage {
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Playlist CantiCristiani',
|
||||
@@ -233,4 +272,42 @@ export class PlaylistPage {
|
||||
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
|
||||
return index !== -1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
async exportPlaylistPdf() {
|
||||
if (this.localSongs.length === 0) return;
|
||||
|
||||
const songSettings = this.getPlaylistSongSettings() || {};
|
||||
const transpositions: { [songId: string]: number } = {};
|
||||
for (const key of Object.keys(songSettings)) {
|
||||
if (songSettings[key] && songSettings[key].tonalita !== undefined) {
|
||||
transpositions[key] = songSettings[key].tonalita;
|
||||
}
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Esporta in PDF',
|
||||
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Solo Testo',
|
||||
handler: () => {
|
||||
const name = this.savedPlaylistName || 'Playlist';
|
||||
this.playlistService.printPlaylist(name, this.localSongs, false, transpositions);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Testo e Accordi',
|
||||
handler: () => {
|
||||
const name = this.savedPlaylistName || 'Playlist';
|
||||
this.playlistService.printPlaylist(name, this.localSongs, true, transpositions);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
<div class="drag-overlay" *ngIf="isDraggingOver">
|
||||
<div class="drag-message">
|
||||
<ion-icon name="image-outline"></ion-icon>
|
||||
<p>Rilascia l'immagine qui per estrarre il testo</p>
|
||||
<ion-icon name="document-text-outline"></ion-icon>
|
||||
<p>Rilascia l'immagine o il PDF qui per estrarre il testo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,20 +31,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Segment Control for mobile layout -->
|
||||
<ion-segment [(ngModel)]="activeTab" class="custom-mobile-segment ion-margin-bottom" mode="md">
|
||||
<ion-segment-button value="editor">
|
||||
<ion-label>Editor</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="preview">
|
||||
<ion-label>Anteprima</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
|
||||
<div class="editor-preview-split">
|
||||
<!-- Left Column: Inputs & Editor -->
|
||||
<div class="editor-column">
|
||||
<div class="editor-column" [class.hide-on-mobile]="activeTab === 'preview'">
|
||||
<ion-list lines="none" class="input-list">
|
||||
<ion-item class="custom-input-item">
|
||||
<ion-label position="stacked">Titolo del Canto</ion-label>
|
||||
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<div class="category-selectors">
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Durata (mm:ss)</ion-label>
|
||||
<ion-input [(ngModel)]="durata" placeholder="Es: 03:45"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">BPM (Tempo)</ion-label>
|
||||
<ion-input type="number" [(ngModel)]="bpm" placeholder="Es: 120"></ion-input>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<div class="category-selectors">
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
||||
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
|
||||
<ion-select-option *ngFor="let lit of sortedLiturgico" [value]="lit.id">
|
||||
{{ lit.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
@@ -53,7 +75,7 @@
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Periodo / Tema</ion-label>
|
||||
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
||||
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
|
||||
<ion-select-option *ngFor="let tem of sortedTematico" [value]="tem.id">
|
||||
{{ tem.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
@@ -63,9 +85,14 @@
|
||||
<!-- TOOLBARS -->
|
||||
<div class="toolbar-section">
|
||||
<div class="horizontal-toolbar">
|
||||
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
|
||||
<ng-container *ngFor="let tag of commonTags">
|
||||
<ion-button size="small" fill="outline" (click)="insertText(tag.start)">
|
||||
{{ tag.label }}
|
||||
</ion-button>
|
||||
<ion-button *ngIf="tag.end" size="small" fill="outline" color="medium" (click)="insertText(tag.end)">
|
||||
Fine {{ tag.label }}
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,35 +126,90 @@
|
||||
<!-- EDITOR AREA -->
|
||||
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title-group">
|
||||
<div class="editor-title-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>Editor Testo</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
|
||||
<ion-icon name="undo-outline"></ion-icon>
|
||||
|
||||
<!-- Transpose buttons in left column, above textarea -->
|
||||
<div class="editor-transpose-group" style="display: flex; align-items: center; gap: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px 6px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); margin-left: 12px;">
|
||||
<ion-button fill="clear" size="small" (click)="transposeDown()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
|
||||
<ion-icon slot="icon-only" name="remove-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
||||
<ion-icon name="camera-outline"></ion-icon>
|
||||
<span class="val outfit-font" style="font-size: 0.85rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 24px; text-align: center;">T:{{ transposeAmount > 0 ? '+' : '' }}{{ transposeAmount }}</span>
|
||||
<ion-button fill="clear" size="small" (click)="transposeUp()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
|
||||
<ion-icon slot="icon-only" name="add-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
<ion-item class="custom-input-item textarea-item">
|
||||
<ion-textarea
|
||||
#contentTextarea
|
||||
[(ngModel)]="content"
|
||||
placeholder="Scrivi o scansiona..."
|
||||
rows="18"
|
||||
class="content-textarea"
|
||||
(paste)="onPaste($event)">
|
||||
</ion-textarea>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<!-- Set default key button -->
|
||||
<ion-button *ngIf="transposeAmount !== 0" fill="outline" color="secondary" size="small" (click)="setDefaultTonalita()" style="margin: 0 0 0 8px; font-size: 10px; font-weight: 700; height: 24px; --border-radius: 6px;">
|
||||
Imposta tonalità di default
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica">
|
||||
<ion-icon name="arrow-undo-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="deduceChords()" [disabled]="!content" title="Deduci accordi per le altre strofe">
|
||||
<ion-icon name="musical-notes-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="takePhoto()" title="Scatta foto">
|
||||
<ion-icon name="camera-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF">
|
||||
<ion-icon name="document-attach-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="toggleRawPasteMode()" [color]="isRawPasteModeActive ? 'warning' : 'medium'" [title]="isRawPasteModeActive ? 'Incolla Raw: Attivo (il testo incollato non verrà filtrato)' : 'Attiva Incolla Raw (incolla senza filtri)'" style="font-size: 11px; font-weight: 800; font-family: 'Outfit', sans-serif;">
|
||||
RAW
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-textarea-container">
|
||||
<!-- Input textarea -->
|
||||
<textarea
|
||||
#nativeTextarea
|
||||
[(ngModel)]="content"
|
||||
placeholder="Scrivi o scansiona..."
|
||||
(paste)="onPaste($event)"
|
||||
spellcheck="false"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<ion-item class="custom-input-item">
|
||||
<ion-label position="stacked">Autore / Link YouTube</ion-label>
|
||||
<ion-label position="stacked">Autore</ion-label>
|
||||
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
|
||||
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<!-- YouTube Link and Player Section -->
|
||||
<div class="custom-input-item youtube-section">
|
||||
<ion-label position="stacked" style="color: #64ffda; font-family: 'Outfit', sans-serif; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">Link YouTube & Audio Player</ion-label>
|
||||
<div class="youtube-input-row" style="display: flex; align-items: center; gap: 8px; margin-top: 6px;">
|
||||
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube" style="flex: 1;"></ion-input>
|
||||
<ion-button fill="solid" color="secondary" size="small" [disabled]="!youtubeLink" (click)="loadEditorAudio()" style="margin: 0; font-weight: 700;">
|
||||
Carica
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Small Player controls when playing/loaded -->
|
||||
<div class="editor-mini-player" *ngIf="isAudioLoaded()" style="display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 36px; width: 36px;">
|
||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'" style="font-size: 1.6rem;"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: right;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||
<ion-range
|
||||
[min]="0"
|
||||
[max]="youtubePlayerService.videoDuration()"
|
||||
[value]="youtubePlayerService.videoProgress()"
|
||||
(ionChange)="onSeek($event)"
|
||||
color="secondary"
|
||||
style="margin: 0; padding: 0 4px; flex: 1; --bar-height: 4px; --knob-size: 12px;">
|
||||
</ion-range>
|
||||
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: left;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ion-list>
|
||||
|
||||
<div class="action-buttons">
|
||||
@@ -139,11 +221,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 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-header">
|
||||
<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>
|
||||
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
|
||||
</ion-button>
|
||||
@@ -154,20 +237,21 @@
|
||||
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
|
||||
</div>
|
||||
<div class="preview-lyrics-container">
|
||||
<div class="lyrics-view">
|
||||
<div *ngFor="let section of parsedSections"
|
||||
class="preview-section"
|
||||
class="section"
|
||||
[class.chorus]="section.type === 'chorus'"
|
||||
[class.verse-num]="section.type === 'verse_num'">
|
||||
|
||||
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div>
|
||||
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="preview-section-label preview-verse-num-label">{{ section.verseNumber }}.</div>
|
||||
<div *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">
|
||||
<div *ngFor="let line of section.lines" class="lyric-line">
|
||||
<!-- Chord mode -->
|
||||
<ng-container *ngIf="showChordsPreview; else textOnly">
|
||||
<span *ngFor="let seg of line.segments; let i = index" class="preview-chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||
<span *ngIf="seg.chord" class="preview-chord">{{ seg.chord }}</span>
|
||||
<span class="preview-seg-text">{{ seg.text }}</span>
|
||||
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||
<span class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
</ng-container>
|
||||
<!-- Text only mode -->
|
||||
@@ -176,6 +260,7 @@
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-empty" *ngIf="!content">
|
||||
Il testo formattato apparirà qui mentre scrivi...
|
||||
@@ -187,8 +272,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Hidden inputs -->
|
||||
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
|
||||
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
|
||||
</ion-content>
|
||||
|
||||
@@ -94,11 +94,14 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
|
||||
/* Textarea inside high contrast must have white background and black text */
|
||||
.content-textarea {
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
.editor-textarea-container {
|
||||
background: #ffffff !important;
|
||||
--background: #ffffff !important;
|
||||
border: 2px solid #000000 !important;
|
||||
|
||||
textarea {
|
||||
color: #000000 !important;
|
||||
caret-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Force background of inputs and items to be white with single dark gray border in high contrast */
|
||||
@@ -267,30 +270,49 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
background: #ffffff !important;
|
||||
--background: #ffffff !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
.editor-textarea-container {
|
||||
background: #ffffff;
|
||||
|
||||
textarea {
|
||||
color: #000000;
|
||||
caret-color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.content-textarea {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
--color: #ffffff !important;
|
||||
.editor-textarea-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
background: #111111;
|
||||
border-radius: 0 0 14px 14px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
textarea {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0 !important;
|
||||
padding: 16px !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||
font-size: 1.05rem !important;
|
||||
line-height: 1.6 !important;
|
||||
color: #ffffff !important;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--padding-top: 16px;
|
||||
--padding-bottom: 16px;
|
||||
min-height: 400px;
|
||||
background: transparent !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box !important;
|
||||
resize: none;
|
||||
overflow-y: auto !important;
|
||||
caret-color: #64ffda !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.category-selectors {
|
||||
@@ -569,8 +591,14 @@ body.high-contrast :host ::ng-deep {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin-bottom: 1.8rem;
|
||||
.lyrics-view {
|
||||
max-width: 900px;
|
||||
margin-left: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
|
||||
&.chorus {
|
||||
@@ -581,25 +609,30 @@ body.high-contrast :host ::ng-deep {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.preview-section-label {
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-bottom: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-lyric-line {
|
||||
margin-bottom: 0.8rem;
|
||||
.lyric-line {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
min-height: 1.5em;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.preview-chord-segment {
|
||||
.chord-segment {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
vertical-align: bottom;
|
||||
@@ -609,15 +642,16 @@ body.high-contrast :host ::ng-deep {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.preview-chord {
|
||||
font-size: 0.78em;
|
||||
.chord {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
height: 1.25em;
|
||||
height: 1.2em;
|
||||
margin-bottom: -0.2em;
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
|
||||
.preview-seg-text {
|
||||
.seg-text {
|
||||
white-space: pre;
|
||||
&::after {
|
||||
content: '\200b';
|
||||
@@ -653,6 +687,16 @@ body.high-contrast :host ::ng-deep {
|
||||
--color: #000000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-transpose-group {
|
||||
border-color: #000000 !important;
|
||||
span {
|
||||
color: #000000 !important;
|
||||
}
|
||||
ion-button {
|
||||
--color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
@@ -671,23 +715,23 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
|
||||
.preview-lyrics-container {
|
||||
.preview-section {
|
||||
.section {
|
||||
&.chorus {
|
||||
background: #f5f5f5;
|
||||
border-left: 3px solid #000000;
|
||||
}
|
||||
|
||||
.preview-section-label {
|
||||
.section-label {
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-lyric-line {
|
||||
.lyric-line {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.preview-chord-segment {
|
||||
.preview-chord {
|
||||
.chord-segment {
|
||||
.chord {
|
||||
color: #000000;
|
||||
text-decoration: underline;
|
||||
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 { 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 { MyCantiService } from '../../services/my-canti.service';
|
||||
import { PlaylistService } from '../../services/playlist.service';
|
||||
@@ -41,6 +41,7 @@ describe('ProposeCantoPage', () => {
|
||||
const navCtrlMock = {};
|
||||
const toastControllerMock = {};
|
||||
const popoverControllerMock = {};
|
||||
const alertControllerMock = {};
|
||||
const angularDelegateMock = {};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -55,7 +56,8 @@ describe('ProposeCantoPage', () => {
|
||||
{ provide: NavController, useValue: navCtrlMock },
|
||||
{ provide: ToastController, useValue: toastControllerMock },
|
||||
{ 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#');
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<!-- PWA Install Group (Desktop Fallback Instructions) -->
|
||||
<div class="settings-group glass ion-margin-bottom" *ngIf="!settingsService.showInstallButton() && !settingsService.isStandalone() && !settingsService.isIos()">
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font" style="white-space: normal;">
|
||||
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold; margin-bottom: 6px;">Come installare l'applicazione</h2>
|
||||
<p class="settings-item-subtitle" style="font-size: 0.85rem; line-height: 1.45; opacity: 0.9; color: var(--ion-text-color); margin: 0;">
|
||||
Se usi <strong>Chrome / Edge / Brave</strong>: puoi installarla cliccando sull'icona di installazione <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">⊕</span> o <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">📥</span> che appare a destra nella barra degli indirizzi del browser.<br><br>
|
||||
Se usi <strong>Safari (su Mac)</strong>: clicca sul menu <strong>File</strong> in alto e seleziona <strong>Aggiungi al Dock...</strong>.
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<div class="settings-group glass ion-margin-bottom">
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
|
||||
@@ -104,6 +118,14 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="musical-notes-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Visualizza durata, bpm e tonalità</h2>
|
||||
<p class="settings-item-subtitle">Mostra queste info sotto autore / titolo</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showDurationBpmTonality()" (ionChange)="settingsService.toggleShowDurationBpmTonality()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
@@ -136,6 +158,14 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.landscapeProjectionEnabled()" (ionChange)="settingsService.toggleLandscapeProjectionEnabled()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||
<ion-icon name="car-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Schermo nero durante l'ascolto</h2>
|
||||
<p class="settings-item-subtitle">Se attiva la riproduzione in home, mostra uno schermo nero per risparmiare batteria. Tocca per disattivare.</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.carModeBlackScreen()" (ionChange)="settingsService.toggleCarModeBlackScreen()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<!-- Comunità -->
|
||||
@@ -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;">
|
||||
<input type="text"
|
||||
#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;"
|
||||
class="outfit-font"
|
||||
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
|
||||
@@ -251,6 +281,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Importa Playlist -->
|
||||
<div class="settings-group glass ion-margin-bottom">
|
||||
<ion-item class="transparent-item" lines="none" button (click)="importPlaylistViaQr()">
|
||||
<ion-icon name="qr-code-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Importa Playlist via QR</h2>
|
||||
<p class="settings-item-subtitle">Inquadra il QR di una playlist condivisa per importarla</p>
|
||||
</ion-label>
|
||||
<ion-icon name="chevron-forward-outline" slot="end" color="medium" style="font-size: 1rem;"></ion-icon>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<!-- Invio Dati Statistici -->
|
||||
<div class="settings-group glass ion-margin-bottom">
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
|
||||
@@ -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() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Ripristina con Codice',
|
||||
@@ -159,7 +172,9 @@ export class SettingsPage {
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
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) {
|
||||
@@ -347,7 +362,9 @@ export class SettingsPage {
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, signal, inject, effect } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface Suggestion {
|
||||
id_canto?: number;
|
||||
@@ -127,8 +128,19 @@ export class CantiLettureService {
|
||||
|
||||
const savedDate = localStorage.getItem('selected-mass-date');
|
||||
if (savedDate) {
|
||||
const masses = this.availableMasses();
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
// Only restore savedDate if it exists in available masses and is not outdated
|
||||
const isValidAndNotPast = masses.some(m => m.date === savedDate && m.date >= todayStr);
|
||||
if (isValidAndNotPast) {
|
||||
this.selectedMassDate.set(savedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
await this.fetchData();
|
||||
@@ -137,24 +149,45 @@ export class CantiLettureService {
|
||||
async fetchData() {
|
||||
try {
|
||||
let fetchedData: CantiLettureData | null = null;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||
const primaryUrl = isProduction
|
||||
? `${window.location.origin}${this.SECURE_JSON_URL}`
|
||||
: 'https://www.canticristiani.it/api/cantiletture.json';
|
||||
const fallbackUrl = isProduction
|
||||
? 'https://www.canticristiani.it/api/cantiletture.json'
|
||||
: this.JSON_URL;
|
||||
|
||||
// 1. Try static JSON endpoint first
|
||||
try {
|
||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
if (res && res.masses && res.week_end && res.week_end >= todayStr) {
|
||||
fetchedData = res;
|
||||
} else {
|
||||
console.warn('Primary JSON data is missing or out of date:', res?.week_end);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err);
|
||||
console.warn('Primary fetch failed:', err);
|
||||
}
|
||||
|
||||
// 2. Fallback to direct API endpoint using Basic Auth credentials
|
||||
if (!fetchedData) {
|
||||
try {
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`));
|
||||
} catch (fallbackErr) {
|
||||
console.error('Fallback fetch also failed:', fallbackErr);
|
||||
console.log('Fetching mass data from direct API:', this.JSON_URL);
|
||||
const authUser = environment.apiAuthUser || 'canti';
|
||||
const authPass = environment.apiAuthPass || 'antani2026';
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': 'Basic ' + btoa(`${authUser}:${authPass}`)
|
||||
});
|
||||
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${this.JSON_URL}?t=${Date.now()}`, { headers }));
|
||||
if (res && res.masses) {
|
||||
fetchedData = res;
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.error('Direct API fetch failed:', authErr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +227,17 @@ export class CantiLettureService {
|
||||
|
||||
this.availableMasses.set(massesList);
|
||||
|
||||
// Always select today's mass if available on load, else find closest future date
|
||||
if (massesList.length > 0) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
const currentSelected = this.selectedMassDate();
|
||||
const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr);
|
||||
|
||||
if (!isCurrentValid) {
|
||||
const match = massesList.find(m => m.date === todayStr);
|
||||
if (match) {
|
||||
this.selectedMassDate.set(match.date);
|
||||
@@ -216,6 +253,7 @@ export class CantiLettureService {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMass(date: string) {
|
||||
this.selectedMassDate.set(date);
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface Canto {
|
||||
data_update?: string;
|
||||
nonValidato?: boolean;
|
||||
isPersonal?: boolean;
|
||||
durata?: string;
|
||||
bpm?: number;
|
||||
}
|
||||
|
||||
export interface Indice {
|
||||
@@ -48,7 +50,12 @@ export class CantiService {
|
||||
public progress = signal<number>(0);
|
||||
public firstLoadCompleted = signal<boolean>(false);
|
||||
|
||||
private API_URL = 'https://www.canticristiani.it/api/canti.json';
|
||||
private getApiUrl(): string {
|
||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||
return isProduction
|
||||
? `${window.location.origin}/api/canti.json`
|
||||
: 'https://www.canticristiani.it/api/canti.json';
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
@@ -92,7 +99,7 @@ export class CantiService {
|
||||
this.loading.set(true);
|
||||
this.progress.set(0);
|
||||
|
||||
this.http.get(`${this.API_URL}?t=${Date.now()}`, {
|
||||
this.http.get(`${this.getApiUrl()}?t=${Date.now()}`, {
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}).subscribe({
|
||||
|
||||
@@ -187,7 +187,9 @@ export class ComunitaService {
|
||||
id_momenti: [],
|
||||
data_update: cp.data_update || '',
|
||||
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);
|
||||
|
||||
@@ -9,63 +9,57 @@ export class FaceDetectorService {
|
||||
public isTilted = signal<boolean>(false);
|
||||
|
||||
private stream: MediaStream | null = null;
|
||||
private camera: any = null;
|
||||
private animFrameId: number | null = null;
|
||||
private faceMesh: any = null;
|
||||
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
|
||||
|
||||
// Gesture state machine
|
||||
private tiltStartTime: number = 0;
|
||||
private inCooldown: boolean = false;
|
||||
private 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 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() {}
|
||||
|
||||
/**
|
||||
* Loads MediaPipe scripts dynamically if not already loaded.
|
||||
* Loads MediaPipe script dynamically if not already loaded.
|
||||
*/
|
||||
private loadScripts(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).FaceMesh && (window as any).Camera) {
|
||||
if ((window as any).FaceMesh) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraScript = document.createElement('script');
|
||||
cameraScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js';
|
||||
cameraScript.crossOrigin = 'anonymous';
|
||||
|
||||
const faceMeshScript = document.createElement('script');
|
||||
faceMeshScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js';
|
||||
faceMeshScript.crossOrigin = 'anonymous';
|
||||
|
||||
cameraScript.onload = () => {
|
||||
document.head.appendChild(faceMeshScript);
|
||||
};
|
||||
|
||||
faceMeshScript.onload = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
cameraScript.onerror = (err) => reject(err);
|
||||
faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
|
||||
faceMeshScript.onload = () => resolve();
|
||||
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> {
|
||||
if (this.isCameraActive()) return;
|
||||
this.onTiltCallback = onTilt;
|
||||
|
||||
try {
|
||||
await this.loadScripts();
|
||||
|
||||
// Request camera permissions and stream
|
||||
const isStreamActive = this.stream &&
|
||||
this.stream.active &&
|
||||
this.stream.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!isStreamActive) {
|
||||
// Request camera permissions and stream ONCE
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 320 },
|
||||
@@ -74,21 +68,27 @@ export class FaceDetectorService {
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
}
|
||||
|
||||
videoElement.srcObject = this.stream;
|
||||
videoElement.setAttribute('playsinline', 'true');
|
||||
(videoElement as any).playsInline = true;
|
||||
videoElement.muted = true;
|
||||
videoElement.play();
|
||||
|
||||
try {
|
||||
await videoElement.play();
|
||||
} catch (playErr) {
|
||||
console.warn('[FaceDetector] Video play warning:', playErr);
|
||||
}
|
||||
|
||||
if (!this.faceMesh) {
|
||||
const FaceMeshLib = (window as any).FaceMesh;
|
||||
const CameraLib = (window as any).Camera;
|
||||
|
||||
if (!FaceMeshLib || !CameraLib) {
|
||||
throw new Error('MediaPipe libraries failed to initialize.');
|
||||
if (!FaceMeshLib) {
|
||||
throw new Error('MediaPipe FaceMesh library failed to initialize.');
|
||||
}
|
||||
|
||||
this.faceMesh = new FaceMeshLib({
|
||||
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
|
||||
locateFile: (file: string) => `assets/mediapipe/${file}`
|
||||
});
|
||||
|
||||
this.faceMesh.setOptions({
|
||||
@@ -101,19 +101,14 @@ export class FaceDetectorService {
|
||||
this.faceMesh.onResults((results: any) => {
|
||||
this.processLandmarks(results);
|
||||
});
|
||||
|
||||
this.camera = new CameraLib(videoElement, {
|
||||
onFrame: async () => {
|
||||
if (this.isCameraActive() && this.faceMesh) {
|
||||
await this.faceMesh.send({ image: videoElement });
|
||||
}
|
||||
},
|
||||
width: 320,
|
||||
height: 240
|
||||
});
|
||||
|
||||
this.isCameraActive.set(true);
|
||||
await this.camera.start();
|
||||
|
||||
// Start custom frame processing loop
|
||||
this.stopFrameLoop();
|
||||
this.startFrameLoop(videoElement);
|
||||
|
||||
console.log('[FaceDetector] Face tracking started successfully.');
|
||||
} catch (err) {
|
||||
console.error('[FaceDetector] Failed to start face tracking:', err);
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -155,7 +182,7 @@ export class FaceDetectorService {
|
||||
if (absAngle > this.TILT_THRESHOLD) {
|
||||
this.isTilted.set(true);
|
||||
|
||||
if (!this.inCooldown) {
|
||||
if (!this.inCooldown && (Date.now() - this.lastTriggerTime > this.TRIGGER_COOLDOWN_MS)) {
|
||||
if (this.tiltStartTime === 0) {
|
||||
this.tiltStartTime = Date.now();
|
||||
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
|
||||
@@ -167,6 +194,7 @@ export class FaceDetectorService {
|
||||
}
|
||||
this.inCooldown = true;
|
||||
this.tiltStartTime = 0;
|
||||
this.lastTriggerTime = Date.now();
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
this.isCameraActive.set(false);
|
||||
this.pause();
|
||||
this.currentTiltAngle.set(0);
|
||||
this.isTilted.set(false);
|
||||
this.inCooldown = false;
|
||||
this.tiltStartTime = 0;
|
||||
|
||||
if (this.camera) {
|
||||
try {
|
||||
this.camera.stop();
|
||||
} catch (e) {}
|
||||
this.camera = null;
|
||||
}
|
||||
this.lastTriggerTime = 0;
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
@@ -210,6 +240,6 @@ export class FaceDetectorService {
|
||||
}
|
||||
|
||||
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].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 = [];
|
||||
currentRawLines = [];
|
||||
currentAction = null;
|
||||
currentType = 'verse';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip structural tags (already handled above)
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}') && !trimmed.startsWith('{c:') && !trimmed.startsWith('{comment:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// Parse line
|
||||
@@ -188,9 +194,13 @@ export class LyricsParserService {
|
||||
* sei Re Gesù[SOL] → text "sei Re Gesù" then chord "SOL" with empty text
|
||||
*/
|
||||
parseChordLine(line: string): ParsedLine {
|
||||
// Treat vertical bars (|) as chords instead of text
|
||||
let prepared = line.replace(/\[\|\]/g, '|');
|
||||
prepared = prepared.replace(/\|/g, '[|]');
|
||||
|
||||
const segments: ChordSegment[] = [];
|
||||
// Clean up non-breaking spaces
|
||||
const cleaned = line.replace(/\u00a0/g, ' ').trim();
|
||||
const cleaned = prepared.replace(/\u00a0/g, ' ').trim();
|
||||
|
||||
// Regex to match [CHORD] tags and text between them
|
||||
const chordRegex = /\[([^\]]+)\]/g;
|
||||
@@ -245,6 +255,12 @@ export class LyricsParserService {
|
||||
*/
|
||||
transposeChord(chord: string, semitones: number): string {
|
||||
if (!chord) return chord;
|
||||
if (chord === '|') return '|';
|
||||
|
||||
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
|
||||
chord = chord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
|
||||
return p1 + (p2 || '') + 'm' + (p3 || '');
|
||||
});
|
||||
|
||||
// Handle slash chords (e.g., DO/SOL)
|
||||
if (chord.includes('/')) {
|
||||
@@ -329,6 +345,11 @@ export class LyricsParserService {
|
||||
isContiguousNext(segments: ChordSegment[], index: number): boolean {
|
||||
if (!segments || index >= segments.length - 1) return false;
|
||||
|
||||
// Se sia il segmento corrente che il successivo hanno un accordo, non sono contigui (vogliamo dello spazio tra loro)
|
||||
if (segments[index].chord && segments[index + 1].chord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the next segment with non-empty text
|
||||
let nextWithText: ChordSegment | null = null;
|
||||
for (let i = index + 1; i < segments.length; i++) {
|
||||
@@ -351,5 +372,42 @@ export class LyricsParserService {
|
||||
|
||||
return endsWithNonSpace && startsWithNonSpace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduce the musical key (tonality) of the song by looking at the first chord.
|
||||
*/
|
||||
deduceTonality(raw: string): string | null {
|
||||
if (!raw) return null;
|
||||
const matches = [...raw.matchAll(/\[([^\]]+)\]/g)];
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
// Get the first chord
|
||||
let firstChord = matches[0][1].trim();
|
||||
if (firstChord.includes('/')) {
|
||||
firstChord = firstChord.split('/')[0].trim();
|
||||
}
|
||||
|
||||
// Normalize minor indicators (uppercase M/N to m)
|
||||
firstChord = firstChord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
|
||||
return p1 + (p2 || '') + 'm';
|
||||
});
|
||||
|
||||
// Find the root chord name matching the scales
|
||||
const possibleRoots = [...this.scale, ...this.flatScale].sort((a, b) => b.length - a.length);
|
||||
let root = '';
|
||||
const upperChord = firstChord.toUpperCase();
|
||||
for (const r of possibleRoots) {
|
||||
if (upperChord.startsWith(r.toUpperCase())) {
|
||||
root = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!root) return null;
|
||||
|
||||
// Check if it is a minor chord
|
||||
const isMinor = firstChord.toLowerCase().includes('m') || firstChord.includes('-');
|
||||
return root + (isMinor ? 'm' : '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { CantiService } from './canti.service';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import { PlaylistService } from './playlist.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -10,6 +11,7 @@ export class MediaSessionService {
|
||||
private cantiService = inject(CantiService);
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
private playlistService = inject(PlaylistService);
|
||||
|
||||
public updateMetadata(cantoId: string) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
@@ -21,6 +23,12 @@ export class MediaSessionService {
|
||||
if (!canto) {
|
||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
||||
}
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (!canto) return;
|
||||
|
||||
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
|
||||
|
||||
@@ -17,17 +17,21 @@ export class MyCantiService {
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
public myCanti = signal<Canto[]>([]);
|
||||
private initPromise!: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
this.initPromise = this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
async init(): Promise<void> {
|
||||
this._storage = this.cantiService.getStorage();
|
||||
if (!this._storage) {
|
||||
// If CantiService hasn't initialized storage yet, wait a bit
|
||||
setTimeout(() => this.init(), 500);
|
||||
return;
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
await this.init();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
const saved = await this._storage.get('my-canti');
|
||||
if (saved) {
|
||||
@@ -36,6 +40,7 @@ export class MyCantiService {
|
||||
}
|
||||
|
||||
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
|
||||
await this.initPromise;
|
||||
const current = this.myCanti();
|
||||
let updated: Canto[];
|
||||
let targetCanto: Canto;
|
||||
@@ -51,7 +56,9 @@ export class MyCantiService {
|
||||
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
|
||||
autore: canto.autore !== undefined ? canto.autore : c.autore,
|
||||
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
|
||||
id_momenti: canto.id_momenti || c.id_momenti
|
||||
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;
|
||||
}
|
||||
@@ -69,7 +76,9 @@ export class MyCantiService {
|
||||
accordi: canto.accordi,
|
||||
autore: canto.autore,
|
||||
link_youtube: canto.link_youtube,
|
||||
id_momenti: canto.id_momenti || []
|
||||
id_momenti: canto.id_momenti || [],
|
||||
durata: canto.durata,
|
||||
bpm: canto.bpm
|
||||
};
|
||||
updated.push(targetCanto);
|
||||
}
|
||||
@@ -83,7 +92,9 @@ export class MyCantiService {
|
||||
accordi: canto.accordi,
|
||||
autore: canto.autore,
|
||||
link_youtube: canto.link_youtube,
|
||||
id_momenti: canto.id_momenti || []
|
||||
id_momenti: canto.id_momenti || [],
|
||||
durata: canto.durata,
|
||||
bpm: canto.bpm
|
||||
};
|
||||
updated = [...current, targetCanto];
|
||||
}
|
||||
@@ -108,6 +119,7 @@ export class MyCantiService {
|
||||
}
|
||||
|
||||
async deleteCanto(id: string) {
|
||||
await this.initPromise;
|
||||
const updated = this.myCanti().filter(c => c.id !== id);
|
||||
this.myCanti.set(updated);
|
||||
await this._storage?.set('my-canti', updated);
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Storage } from '@ionic/storage-angular';
|
||||
import { Canto, CantiService } from './canti.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { ToastController, AlertController } from '@ionic/angular';
|
||||
import { ToastController, AlertController, ModalController } from '@ionic/angular';
|
||||
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 { LyricsParserService } from './lyrics-parser.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -13,9 +15,11 @@ import { MyCantiService } from './my-canti.service';
|
||||
export class PlaylistService {
|
||||
private storage = inject(Storage);
|
||||
private cantiService = inject(CantiService);
|
||||
private lyricsParser = inject(LyricsParserService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
private toastCtrl = inject(ToastController);
|
||||
private alertCtrl = inject(AlertController);
|
||||
private modalCtrl = inject(ModalController);
|
||||
private settingsService = inject(SettingsService);
|
||||
private injector = inject(Injector);
|
||||
private myCantiService!: MyCantiService;
|
||||
@@ -29,10 +33,12 @@ export class PlaylistService {
|
||||
public activeListIds = signal<string[]>([]);
|
||||
public activeListName = signal<string | null>(null);
|
||||
public activePlaylistId = signal<string | null>(null);
|
||||
public filteredListIds = signal<string[]>([]);
|
||||
|
||||
public remotePlaylist = signal<any | null>(null);
|
||||
public remoteCustomSongs = signal<any[]>([]);
|
||||
public remoteShareCanti = signal<Canto[]>([]);
|
||||
public hasRemotePlaylistUpdate = signal<boolean>(false);
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
private initPromise!: Promise<void>;
|
||||
@@ -43,10 +49,7 @@ export class PlaylistService {
|
||||
return [];
|
||||
}
|
||||
return this.comunitaService.comunitaScalette().map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
ids: s.ids,
|
||||
createdAt: s.date,
|
||||
...s,
|
||||
isComunita: true
|
||||
}));
|
||||
});
|
||||
@@ -83,21 +86,23 @@ export class PlaylistService {
|
||||
|
||||
getPlaylistsStorageKey(): string {
|
||||
const code = this.comunitaService.comunitaCode();
|
||||
const isCommunityActive = this.comunitaService.isFilterActive();
|
||||
if (code && isCommunityActive) {
|
||||
return `playlists_comunita_${code}`;
|
||||
const active = this.comunitaService.isFilterActive();
|
||||
if (code && active) {
|
||||
return `playlists_${code}`;
|
||||
}
|
||||
return 'playlists';
|
||||
}
|
||||
|
||||
async init() {
|
||||
const storage = await this.storage.create();
|
||||
this._storage = storage;
|
||||
await this.loadPlaylistsForCurrentContext();
|
||||
async init(): Promise<void> {
|
||||
this._storage = this.cantiService.getStorage();
|
||||
if (!this._storage) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
await this.init();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
async loadPlaylistsForCurrentContext() {
|
||||
if (!this._storage) return;
|
||||
const key = this.getPlaylistsStorageKey();
|
||||
const saved = await this._storage.get(key);
|
||||
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() {
|
||||
const remotePl = this.remotePlaylist();
|
||||
if (!remotePl) return;
|
||||
@@ -149,7 +221,9 @@ export class PlaylistService {
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
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
|
||||
@@ -177,6 +251,12 @@ export class PlaylistService {
|
||||
this.remoteCustomSongs.set(customSongs);
|
||||
await this._storage?.set('remote_playlist', updatedPl);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -257,6 +337,52 @@ export class PlaylistService {
|
||||
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) {
|
||||
await this.initPromise;
|
||||
this.playlists.update(p => p.map(pl => {
|
||||
@@ -282,15 +408,14 @@ export class PlaylistService {
|
||||
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;
|
||||
this.playlists.update(p => p.map(pl => {
|
||||
if (pl.id === playlistId) {
|
||||
const songSettings = { ...(pl.songSettings || {}) };
|
||||
songSettings[songId] = {
|
||||
tonalita,
|
||||
speed,
|
||||
zoom: zoom !== undefined ? zoom : songSettings[songId]?.zoom
|
||||
speed
|
||||
};
|
||||
return { ...pl, songSettings };
|
||||
}
|
||||
@@ -368,7 +493,9 @@ export class PlaylistService {
|
||||
accordi: c.accordi || '',
|
||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||
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 || '',
|
||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||
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,10 +670,6 @@ export class PlaylistService {
|
||||
const activeId = this.activePlaylistId() || Date.now().toString();
|
||||
const isRemote = activeId.startsWith('remote_');
|
||||
|
||||
const buttons: any[] = [
|
||||
{
|
||||
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
|
||||
handler: () => {
|
||||
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
|
||||
if (isRemote) {
|
||||
let remoteUid = uid;
|
||||
@@ -524,82 +681,15 @@ export class PlaylistService {
|
||||
}
|
||||
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
|
||||
}
|
||||
this.executeShare(shareLink, name);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (!isRemote) {
|
||||
buttons.push({
|
||||
text: 'Modifica (Collaborazione / Backup)',
|
||||
handler: () => {
|
||||
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
|
||||
this.executeShare(shareLink, name + ' (Editor)');
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: SharePlaylistQrModalComponent,
|
||||
componentProps: {
|
||||
playlistName: name,
|
||||
shareLink: shareLink
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
});
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Condividi Playlist',
|
||||
message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:',
|
||||
buttons: buttons
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
private async executeShare(shareLink: string, name: string) {
|
||||
// Generate QR using the link
|
||||
const qrImage = await QRCode.toDataURL(shareLink, {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#2d3436',
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
|
||||
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async loadPlaylists() {
|
||||
@@ -625,4 +715,560 @@ export class PlaylistService {
|
||||
this.remoteShareCanti.set(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 */
|
||||
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 */
|
||||
public enableStandardAutoscroll = signal<boolean>(false);
|
||||
|
||||
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
|
||||
public enableVisualAutoscroll = signal<boolean>(true);
|
||||
|
||||
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
|
||||
public cameraNavigationActive = signal<boolean>(false);
|
||||
|
||||
/** Preferenza notazione accordi: diesis o bemolle */
|
||||
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
||||
|
||||
@@ -54,7 +60,13 @@ export class SettingsService {
|
||||
public karaokePageScrollMode = signal<boolean>(false);
|
||||
|
||||
/** 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à */
|
||||
public userUuid = signal<string>('');
|
||||
@@ -128,6 +140,8 @@ export class SettingsService {
|
||||
this.deferredPrompt.set(e);
|
||||
// Update UI notify the user they can install the PWA
|
||||
this.showInstallButton.set(true);
|
||||
// Se scatta l'evento di installazione, l'app NON è attualmente installata
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
@@ -149,6 +163,7 @@ export class SettingsService {
|
||||
localStorage.setItem('invio-dati-statistici', 'false');
|
||||
localStorage.setItem('show-tags-in-list', 'true');
|
||||
localStorage.setItem('show-update-date', 'true');
|
||||
localStorage.setItem('show-duration-bpm-tonality', 'true');
|
||||
localStorage.setItem('enable-standard-autoscroll', 'false');
|
||||
localStorage.setItem('enable-visual-autoscroll', 'true');
|
||||
localStorage.setItem('chord-notation-preference', 'diesis');
|
||||
@@ -219,6 +234,13 @@ export class SettingsService {
|
||||
this.showUpdateDate.set(true);
|
||||
}
|
||||
|
||||
const savedShowDurBpmTon = localStorage.getItem('show-duration-bpm-tonality');
|
||||
if (savedShowDurBpmTon !== null) {
|
||||
this.showDurationBpmTonality.set(savedShowDurBpmTon === 'true');
|
||||
} else {
|
||||
this.showDurationBpmTonality.set(true);
|
||||
}
|
||||
|
||||
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
||||
if (savedStandardAutoscroll !== null) {
|
||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||
@@ -251,7 +273,22 @@ export class SettingsService {
|
||||
if (savedLandscapeProjectionEnabled !== null) {
|
||||
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
|
||||
} 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)
|
||||
@@ -291,6 +328,10 @@ export class SettingsService {
|
||||
localStorage.setItem('show-update-date', this.showUpdateDate().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('show-duration-bpm-tonality', this.showDurationBpmTonality().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
|
||||
});
|
||||
@@ -311,6 +352,25 @@ export class SettingsService {
|
||||
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(() => {
|
||||
const active = this.keepScreenOn();
|
||||
localStorage.setItem('keep-screen-on', active.toString());
|
||||
@@ -422,6 +482,12 @@ export class SettingsService {
|
||||
localStorage.setItem('show-update-date', newValue.toString());
|
||||
}
|
||||
|
||||
toggleShowDurationBpmTonality() {
|
||||
const newValue = !this.showDurationBpmTonality();
|
||||
this.showDurationBpmTonality.set(newValue);
|
||||
localStorage.setItem('show-duration-bpm-tonality', newValue.toString());
|
||||
}
|
||||
|
||||
toggleStandardAutoscroll() {
|
||||
const newValue = !this.enableStandardAutoscroll();
|
||||
this.enableStandardAutoscroll.set(newValue);
|
||||
@@ -446,6 +512,12 @@ export class SettingsService {
|
||||
localStorage.setItem('landscape-projection-enabled', newValue.toString());
|
||||
}
|
||||
|
||||
toggleCarModeBlackScreen() {
|
||||
const newValue = !this.carModeBlackScreen();
|
||||
this.carModeBlackScreen.set(newValue);
|
||||
localStorage.setItem('car-mode-black-screen', newValue.toString());
|
||||
}
|
||||
|
||||
setChordNotationPreference(val: 'diesis' | 'bemolle') {
|
||||
this.chordNotationPreference.set(val);
|
||||
}
|
||||
@@ -464,10 +536,10 @@ export class SettingsService {
|
||||
localStorage.setItem('user-name', trimmed);
|
||||
}
|
||||
|
||||
async installPwa() {
|
||||
async installPwa(): Promise<string | undefined> {
|
||||
const promptEvent = this.deferredPrompt();
|
||||
if (!promptEvent) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
// Show the install prompt
|
||||
promptEvent.prompt();
|
||||
@@ -477,5 +549,6 @@ export class SettingsService {
|
||||
// We've used the prompt, and can't use it again, discard it
|
||||
this.deferredPrompt.set(null);
|
||||
this.showInstallButton.set(false);
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MediaSessionService } from './media-session.service';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { ConnectivityService } from './connectivity.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import { PlaylistService } from './playlist.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -16,6 +17,7 @@ export class YoutubePlayerService {
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private connectivityService = inject(ConnectivityService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
private playlistService = inject(PlaylistService);
|
||||
|
||||
public isPlayerSupported = computed<boolean>(() => {
|
||||
// Check if offline
|
||||
@@ -48,8 +50,9 @@ export class YoutubePlayerService {
|
||||
// Auto-stop player when not supported (e.g. going offline)
|
||||
effect(() => {
|
||||
const supported = this.isPlayerSupported();
|
||||
if (!supported && this.currentCantoId()) {
|
||||
this.stop();
|
||||
if (!supported) {
|
||||
this.isPlaying.set(false);
|
||||
this.destroyPlayer();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -103,8 +106,8 @@ export class YoutubePlayerService {
|
||||
};
|
||||
}
|
||||
|
||||
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void) {
|
||||
if (this.currentCantoId() === cantoId && this.player) {
|
||||
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void, customYoutubeLink?: string) {
|
||||
if (this.currentCantoId() === cantoId && this.player && !customYoutubeLink) {
|
||||
this.onEndedCallback = onEnded || null;
|
||||
this.onErrorCallback = onError || null;
|
||||
|
||||
@@ -120,6 +123,10 @@ export class YoutubePlayerService {
|
||||
this.onEndedCallback = onEnded || null;
|
||||
this.onErrorCallback = onError || null;
|
||||
|
||||
let videoId: string | null = null;
|
||||
if (customYoutubeLink) {
|
||||
videoId = this.cantiService.getYoutubeId(customYoutubeLink);
|
||||
} else {
|
||||
let canto = this.cantiService.getCantoById(cantoId);
|
||||
if (!canto) {
|
||||
canto = this.myCantiService.myCanti().find(c => c.id === cantoId);
|
||||
@@ -127,23 +134,44 @@ export class YoutubePlayerService {
|
||||
if (!canto) {
|
||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
||||
}
|
||||
if (!canto) return;
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (!canto) {
|
||||
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||
}
|
||||
if (canto) {
|
||||
videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
||||
}
|
||||
}
|
||||
|
||||
const videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
||||
if (!videoId) {
|
||||
if (onError) onError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(window as any).YT || !(window as any).YT.Player) {
|
||||
setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError), 200);
|
||||
setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError, customYoutubeLink), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse existing player if possible
|
||||
if (this.player && this.player.loadVideoById) {
|
||||
this.currentCantoId.set(cantoId);
|
||||
if (customYoutubeLink) {
|
||||
if ('mediaSession' in navigator && 'MediaMetadata' in window) {
|
||||
try {
|
||||
(navigator as any).mediaSession.metadata = new (window as any).MediaMetadata({
|
||||
title: 'Anteprima Canto',
|
||||
artist: 'Canti Cristiani',
|
||||
album: 'Canti Cristiani',
|
||||
artwork: [{ src: 'assets/icon/favicon.png', sizes: '512x512', type: 'image/png' }]
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
this.mediaSessionService.updateMetadata(cantoId);
|
||||
}
|
||||
this.player.loadVideoById({
|
||||
videoId: videoId,
|
||||
startSeconds: startTime
|
||||
@@ -154,7 +182,20 @@ export class YoutubePlayerService {
|
||||
|
||||
this.destroyPlayer();
|
||||
this.currentCantoId.set(cantoId);
|
||||
if (customYoutubeLink) {
|
||||
if ('mediaSession' in navigator && 'MediaMetadata' in window) {
|
||||
try {
|
||||
(navigator as any).mediaSession.metadata = new (window as any).MediaMetadata({
|
||||
title: 'Anteprima Canto',
|
||||
artist: 'Canti Cristiani',
|
||||
album: 'Canti Cristiani',
|
||||
artwork: [{ src: 'assets/icon/favicon.png', sizes: '512x512', type: 'image/png' }]
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
this.mediaSessionService.updateMetadata(cantoId);
|
||||
}
|
||||
|
||||
this.player = new (window as any).YT.Player('global-yt-player-container', {
|
||||
height: '1',
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.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>
|
||||
<html lang="en">
|
||||
<html lang="it" class="notranslate">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="google" content="notranslate"/>
|
||||
<title>CantiCristiani</title>
|
||||
|
||||
<base href="/"/>
|
||||
@@ -73,7 +74,11 @@
|
||||
if (options.percent !== undefined) {
|
||||
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
if (pctText) pctText.textContent = pct + '%';
|
||||
if (bar && bar.parentElement) bar.parentElement.style.display = 'block';
|
||||
if (pctText) {
|
||||
pctText.style.display = 'block';
|
||||
pctText.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isRedirect) {
|
||||
@@ -82,7 +87,7 @@
|
||||
if (phaseEl) phaseEl.style.display = 'none';
|
||||
if (versionEl) versionEl.style.display = 'none';
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device';
|
||||
titleEl.textContent = 'Chiudi il browser è stata installata la app sul device';
|
||||
titleEl.style.fontSize = '1.4rem';
|
||||
titleEl.style.lineHeight = '1.5';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user