Compare commits

..

8 Commits

44 changed files with 4416 additions and 900 deletions
+3
View File
@@ -0,0 +1,3 @@
# Regole del Progetto
Quando trovi dei trattini verticali `|` tra gli accordi, ricordati di inserirli all'interno della riga degli accordi (cioè racchiusi tra parentesi quadre, ad esempio come accordo `[|]`), non come testo normale.
+13 -39
View File
@@ -8,41 +8,14 @@ else
exit 1 exit 1
fi fi
# --- Configurazione Target e Parallelismo --- # --- Configurazione Target ---
TARGET="tophost" TARGET="contabo"
THREADS=""
if [ "$1" = "contabo" ] || [ "$1" = "tophost" ]; then
TARGET="$1"
THREADS="$2"
else
# Se il primo argomento è un numero, indica il numero di thread per tophost
if [[ "$1" =~ ^[0-9]+$ ]]; then
THREADS="$1"
fi
fi
THREADS=${THREADS:-${FTP_THREADS:-100}}
export FTP_THREADS=$THREADS
echo "🎯 Target di deploy: $TARGET" echo "🎯 Target di deploy: $TARGET"
# --- Validazione configurazione basata sul target --- # --- Validazione configurazione ---
if [ "$TARGET" = "tophost" ]; then if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
FTP_HOST="ftp.canticristiani.it" echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
FTP_USER="canticristiani.it" exit 1
FTP_PASS="$FTP_PASSWORD"
REMOTE_DIR=""
if [ -z "$FTP_PASS" ]; then
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
exit 1
fi
else
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
exit 1
fi
fi fi
# --- Aggiornamento Versione --- # --- Aggiornamento Versione ---
@@ -84,10 +57,11 @@ echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.j
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)" echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
# --- Upload --- # --- Upload ---
if [ "$TARGET" = "tophost" ]; then echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
echo "🚀 Upload via FTP su Tophost in corso..." rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
python3 scratch/deploy_ftp.py
else # --- Allineamento Cantiletture JSON ---
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..." if [ -f "./allinealetture.sh" ]; then
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH" echo "🔄 Sincronizzazione cantiletture.json su server..."
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
fi fi
+144
View File
@@ -0,0 +1,144 @@
# Parametri letti da canti.json e da Comunità
Questo documento elenca tutti i parametri e i campi strutturati che vengono letti, elaborati e salvati dall'applicazione a partire dal file globale `canti.json` e dalle API/file di configurazione delle **Comunità**.
---
## 1. Parametri da `canti.json`
Il file `canti.json` rappresenta il database globale dei canti dell'applicazione. Viene scaricato dall'endpoint configurato in `CantiService` (es. `https://www.canticristiani.it/api/canti.json`).
### Struttura Principale del File
Il JSON restituito contiene diversi nodi chiave, ciascuno contenente un array `data`:
```json
{
"canti": { "data": [...] },
"indice_liturgico": { "data": [...] },
"indice_tematico": { "data": [...] },
"tema": { "data": [...] },
"canti_eseguiti": { "data": [...] }
}
```
#### Nodo `canti.data` (Lista dei Canti)
Ciascun elemento rappresenta un canto e viene mappato nell'interfaccia `Canto`:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | Identificativo univoco del canto (usato internamente anche come stringa `id`). |
| `titolo` | `string` | Titolo del canto. |
| `testo` | `string` | Testo del canto (può includere indicazioni di accordi). |
| `accordi` | `string` (opzionale) | Accordi musicali associati al canto. |
| `autore` | `string` (opzionale) | Autore o autori del canto. |
| `link_youtube` | `string` (opzionale) | URL o ID del video di YouTube associato al canto. |
| `data_update` | `string` (opzionale) | Data dell'ultimo aggiornamento (formato `YYYY-MM-DD HH:mm:ss`, formattata a schermo in `DD/MM/YYYY`). |
| `nonValidato` | `boolean` (opzionale) | Indica se il canto è in attesa di validazione. |
| `isPersonal` | `boolean` (opzionale) | Flag locale per identificare se si tratta di un canto personale dell'utente. |
*Nota: Durante l'importazione, viene aggiunto un array `id_momenti: number[]` ricavato dalla tabella pivot `tema.data`.*
#### Nodo `indice_liturgico.data` e `indice_tematico.data` (Indici/Tag)
Mappati nell'interfaccia `Indice`:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_indice_liturgico` / `id_indice_tematico` | `number` | Identificativo dell'indice/momento. |
| `tag_name` | `string` | Nome visualizzato del tag (es. "Ingresso", "Offertorio"). |
| `slug` | `string` | Versione ottimizzata per URL del tag. |
*Nota: A livello applicativo viene aggiunto il campo `type` con valore `'liturgico'` o `'tematico'`.*
#### Nodo `tema.data` (Relazione Pivot Canti-Indici)
Utilizzato per associare a ogni canto i rispettivi momenti liturgici o tematici:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto associato. |
| `id_momento` | `number` | ID del momento liturgico/tematico. |
#### Nodo `canti_eseguiti.data`
Informazioni sull'esecuzione dei canti:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto eseguito. |
| `num` | `number` | Numero di esecuzioni o indicatore di frequenza. |
---
## 2. Parametri dalle Comunità
I dati di una comunità vengono letti in due modi: tramite l'API di produzione (v3) oppure tramite un file JSON statico di fallback.
### Opzione A: API di Produzione (`get_all_app_tables`)
Endpoint: `https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables`
L'API risponde con un oggetto contenente diverse tabelle relazionali:
#### 1. `parrocchia.data` (Dettagli della Comunità)
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_parrocchia` | `number` | ID interno della parrocchia/comunità. |
| `nome` | `string` | Nome della comunità (es. "Parrocchia S. Maria"). |
| `codice` | `string` | Codice alfanumerico della comunità. |
| `mail` | `string` | Email di riferimento della comunità. |
| `guid_parrocchia` | `string` | GUID univoco. |
#### 2. `parrocchia_canti.data` (Associazione Canti-Comunità)
Indica quali canti del database generale appartengono al repertorio della comunità:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_parrocchia` | `number` | ID della parrocchia. |
| `id_canti` | `number` | ID del canto associato. |
| `num_canto` | `number` | Numero progressivo o di classificazione del canto all'interno della comunità. |
#### 3. `canti_settings.data` (Impostazioni di Esecuzione personalizzate)
Configurazioni specifiche per l'esecuzione del canto in comunità:
| Parametro Originale | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto. |
| `speed` | `number` | Velocità di scorrimento (auto-scroll) consigliata. |
| `tonalita` | `number` | Semitoni di trasposizione (trasporto tonalità) consigliati. |
#### 4. `canti_personali.data` (Canti personalizzati/inediti della Comunità)
Canti inseriti direttamente dalla comunità e non presenti nel database globale:
| Parametro Originale | Tipo | Descrizione / Mapping Applicativo |
| :--- | :--- | :--- |
| `id_canti` | `number` | ID del canto personale. |
| `titolo` | `string` | Titolo del canto. |
| `accordi` | `string` | Testo con gli accordi del canto. |
| `autore` | `string` | Autore del canto. |
| `link_youtube` | `string` | Link YouTube. |
| `data_update` | `string` | Data dell'ultimo aggiornamento. |
| `stato` | `number` | Se uguale a `10`, il canto viene contrassegnato come `nonValidato = true`. |
*Nota: Vengono impostati automaticamente `isPersonal = true` e `id_momenti = []`.*
#### 5. Scalette della Comunità (`lista_nome` + `lista_esecuzione`)
L'applicazione ricostruisce l'elenco delle scalette (`ComunitaScaletta`):
* **`lista_nome.data`** (Testate delle scalette):
* `id_lista` (`string`/`number`): ID della scaletta.
* `nome` (`string`): Nome della scaletta (es. "Domenica delle Palme").
* `progr` (`string`): Data o stringa di ordinamento (mappata in `date`).
* **`lista_esecuzione.data`** (Canti contenuti nelle scalette):
* `id_lista` (`string`/`number`): Associazione alla scaletta.
* `id_canti` (`number`): ID del canto.
* `progr` (`number`): Ordine progressivo del canto all'interno della scaletta (usato per l'ordinamento).
---
### Opzione B: Fallback Statico (`comunita_[codice].json`)
Se l'API di produzione non è raggiungibile, viene tentato il download di un file statico (es. `comunita_123456.json`) dall'origine del sito.
La struttura attesa per questo file è molto più semplice:
| Parametro JSON | Tipo | Descrizione |
| :--- | :--- | :--- |
| `id_comunita` | `string` | Codice identificativo della comunità. |
| `nome_comunita` | `string` | Nome leggibile della comunità. |
| `canti` | `(number \| string)[]` | Array contenente gli ID di tutti i canti associati a questa comunità. |
+34
View File
@@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
function walkDir(dir, callback) {
fs.readdirSync(dir).forEach(f => {
let dirPath = path.join(dir, f);
let isDirectory = fs.statSync(dirPath).isDirectory();
if (isDirectory) {
walkDir(dirPath, callback);
} else {
callback(dirPath);
}
});
}
const icons = new Set();
walkDir(path.join(__dirname, '../src/app'), (filePath) => {
if (filePath.endsWith('.html') || filePath.endsWith('.ts')) {
const content = fs.readFileSync(filePath, 'utf8');
// Match name="icon-name"
const nameMatches = content.matchAll(/name=["']([a-zA-Z0-9-]+)["']/g);
for (const match of nameMatches) {
icons.add(match[1]);
}
// Match [name]="... ? 'icon-a' : 'icon-b'"
const ternaryMatches = content.matchAll(/'([a-zA-Z0-9-]+-outline|[a-zA-Z0-9-]+-sharp|[a-zA-Z0-9-]+)'/g);
for (const match of ternaryMatches) {
icons.add(match[1]);
}
}
});
console.log(JSON.stringify(Array.from(icons).sort(), null, 2));
+286 -133
View File
@@ -1,13 +1,87 @@
import { Component, inject, OnInit, signal, effect } from '@angular/core'; import { Component, inject, NgZone, OnInit, signal, effect } from '@angular/core';
import { ThemeService } from './services/theme.service'; import { ThemeService } from './services/theme.service';
import { CantiService } from './services/canti.service'; import { CantiService } from './services/canti.service';
import { SettingsService } from './services/settings.service'; import { SettingsService } from './services/settings.service';
import { VERSION } from './version'; import { VERSION } from './version';
import { Router, ActivatedRoute, NavigationStart } from '@angular/router'; import { Router, ActivatedRoute, NavigationStart } from '@angular/router';
import { ToastController, Platform } from '@ionic/angular'; import { ToastController, Platform, AlertController, NavController } from '@ionic/angular';
import { Location } from '@angular/common';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators'; import { filter, first } from 'rxjs/operators';
import { App } from '@capacitor/app'; import { App } from '@capacitor/app';
import { addIcons } from 'ionicons';
import {
add,
addCircleOutline,
addOutline,
analyticsOutline,
arrowUpCircle,
bookOutline,
calendarOutline,
cameraOutline,
cameraReverseOutline,
carOutline,
chevronBack,
chevronBackOutline,
chevronDown,
chevronDownOutline,
chevronForward,
chevronForwardOutline,
chevronUp,
chevronUpOutline,
closeCircle,
closeCircleOutline,
closeOutline,
cloudDownloadOutline,
cloudOfflineOutline,
cloudUploadOutline,
contrastOutline,
copyOutline,
createOutline,
documentAttachOutline,
documentTextOutline,
downloadOutline,
eyeOutline,
informationCircleOutline,
keypadOutline,
listOutline,
logoApple,
logoYoutube,
mic,
micOutline,
musicalNote,
musicalNotesOutline,
pause,
pauseSharp,
peopleOutline,
personOutline,
phoneLandscapeOutline,
play,
playForwardOutline,
playSharp,
playSkipBackSharp,
playSkipForwardSharp,
pricetagsOutline,
qrCodeOutline,
refreshOutline,
remove,
removeCircleOutline,
removeOutline,
reorderTwoOutline,
saveOutline,
scanOutline,
searchOutline,
settingsOutline,
shareOutline,
shareSocialOutline,
sparklesOutline,
statsChartOutline,
swapVerticalOutline,
trashOutline,
arrowUndoOutline,
videocam,
videocamOffOutline
} from 'ionicons/icons';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -23,8 +97,12 @@ export class AppComponent implements OnInit {
private platform = inject(Platform); private platform = inject(Platform);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private location = inject(Location);
private navCtrl = inject(NavController);
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
private alertCtrl = inject(AlertController);
private swUpdate = inject(SwUpdate); private swUpdate = inject(SwUpdate);
private ngZone = inject(NgZone);
public showRedirectOverlay = signal<boolean>(false); public showRedirectOverlay = signal<boolean>(false);
public showInstallOverlay = signal<boolean>(false); public showInstallOverlay = signal<boolean>(false);
@@ -34,11 +112,87 @@ export class AppComponent implements OnInit {
public redirectFailed = signal<boolean>(false); public redirectFailed = signal<boolean>(false);
public protocolLink = ''; public protocolLink = '';
constructor() { constructor() {
// Gli aggiornamenti automatici e periodici sono stati rimossi. // Gli aggiornamenti automatici e periodici sono stati rimossi.
// L'aggiornamento viene gestito esclusivamente in modo manuale // L'aggiornamento viene gestito esclusivamente in modo manuale
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage. // tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
addIcons({
add,
'add-circle-outline': addCircleOutline,
'add-outline': addOutline,
'analytics-outline': analyticsOutline,
'arrow-up-circle': arrowUpCircle,
'book-outline': bookOutline,
'calendar-outline': calendarOutline,
'camera-outline': cameraOutline,
'camera-reverse-outline': cameraReverseOutline,
'car-outline': carOutline,
'chevron-back': chevronBack,
'chevron-back-outline': chevronBackOutline,
'chevron-down': chevronDown,
'chevron-down-outline': chevronDownOutline,
'chevron-forward': chevronForward,
'chevron-forward-outline': chevronForwardOutline,
'chevron-up': chevronUp,
'chevron-up-outline': chevronUpOutline,
'close-circle': closeCircle,
'close-circle-outline': closeCircleOutline,
'close-outline': closeOutline,
'cloud-download-outline': cloudDownloadOutline,
'cloud-offline-outline': cloudOfflineOutline,
'cloud-upload-outline': cloudUploadOutline,
'contrast-outline': contrastOutline,
'copy-outline': copyOutline,
'create-outline': createOutline,
'document-attach-outline': documentAttachOutline,
'document-text-outline': documentTextOutline,
'download-outline': downloadOutline,
'eye-outline': eyeOutline,
'information-circle-outline': informationCircleOutline,
'keypad-outline': keypadOutline,
'list-outline': listOutline,
'logo-apple': logoApple,
'logo-youtube': logoYoutube,
mic,
'mic-outline': micOutline,
'musical-note': musicalNote,
'musical-notes-outline': musicalNotesOutline,
pause,
'pause-sharp': pauseSharp,
'people-outline': peopleOutline,
'person-outline': personOutline,
'phone-landscape-outline': phoneLandscapeOutline,
play,
'play-forward-outline': playForwardOutline,
'play-sharp': playSharp,
'play-skip-back-sharp': playSkipBackSharp,
'play-skip-forward-sharp': playSkipForwardSharp,
'pricetags-outline': pricetagsOutline,
'qr-code-outline': qrCodeOutline,
'refresh-outline': refreshOutline,
remove,
'remove-circle-outline': removeCircleOutline,
'remove-outline': removeOutline,
'reorder-two-outline': reorderTwoOutline,
'save-outline': saveOutline,
'scan-outline': scanOutline,
'search-outline': searchOutline,
'settings-outline': settingsOutline,
'share-outline': shareOutline,
'share-social-outline': shareSocialOutline,
'sparkles-outline': sparklesOutline,
'stats-chart-outline': statsChartOutline,
'swap-vertical-outline': swapVerticalOutline,
'trash-outline': trashOutline,
'arrow-undo-outline': arrowUndoOutline,
videocam,
'videocam-off-outline': videocamOffOutline
});
effect(() => { effect(() => {
this.checkLoaderDismissal(); this.checkLoaderDismissal();
}); });
@@ -62,24 +216,44 @@ export class AppComponent implements OnInit {
} }
async ngOnInit() { async ngOnInit() {
// Gestione tasto back per PWA/Browser (intercettando popstate di Angular Router) // Gestione del tasto back (PWA/Browser e Nativo/Hardware) secondo le 3 specifiche:
this.router.events.subscribe(event => { // 1- Se siamo su un canto (/player o /display), premendo back andiamo sempre sulla home.
if (event instanceof NavigationStart && event.navigationTrigger === 'popstate') { // 2- Se siamo sulla home (/home o /), premendo back l'app deve uscire.
const targetUrl = event.url.split('?')[0]; // 3- Per tutto il resto, segue la logica standard andando alla pagina precedente nello storico.
if (targetUrl !== '/home' && targetUrl !== '/') {
this.router.navigate(['/home'], { replaceUrl: true }); // 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 back hardware per nativo (Capacitor/Cordova) // Gestione tasto indietro hardware (es. Android / Capacitor / PWA)
this.platform.backButton.subscribeWithPriority(9999, () => { this.platform.backButton.subscribeWithPriority(10, async () => {
const currentUrl = this.router.url; const currentPath = this.router.url.split('?')[0];
const path = currentUrl.split('?')[0]; if (currentPath === '/home' || currentPath === '/') {
if (path !== '/home' && path !== '/') { // Spec 2: Sulla home, usciamo dall'app
this.router.navigate(['/home']); 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 { } else {
App.exitApp(); // Spec 3: Per il resto, logica standard (pagina precedente)
if (window.history.length > 1) {
this.location.back();
} else {
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
}
} }
}); });
@@ -158,23 +332,70 @@ export class AppComponent implements OnInit {
console.log('[AppComponent] PWA appinstalled event caught.'); console.log('[AppComponent] PWA appinstalled event caught.');
localStorage.setItem('pwa-installed', 'true'); localStorage.setItem('pwa-installed', 'true');
this.isPwaInstalled.set(true); this.isPwaInstalled.set(true);
this.isInstalling.set(false);
this.isRedirecting.set(true);
this.showInstallOverlay.set(false); this.showInstallOverlay.set(false);
this.showRedirectOverlay.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) { if ((window as any).PwaLoader) {
(window as any).PwaLoader.show(); (window as any).PwaLoader.show();
(window as any).PwaLoader.update({ (window as any).PwaLoader.update({
title: 'Chiudi il browser', title: 'Installazione applicazione',
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.', phase: 'Fase: Registrazione',
isRedirect: true desc: 'Generazione e registrazione dell\'applicazione sul dispositivo in corso...',
percent: 15
}); });
} }
setTimeout(() => { const startTime = Date.now();
window.location.href = this.protocolLink; const TARGET_DURATION_MS = 11000; // 11 secondi reali per completare l'installazione WebAPK
}, 1000);
const timer = setInterval(() => {
const elapsed = Date.now() - startTime;
let pct = Math.min(100, Math.round(15 + (elapsed / TARGET_DURATION_MS) * 85));
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: pct });
}
if (pct >= 100) {
clearInterval(timer);
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Chiudi il browser',
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('[AppComponent] Running on localhost - skipping protocol link redirect on appinstalled.');
this.isRedirecting.set(false);
this.checkLoaderDismissal();
return;
}
setTimeout(() => {
window.location.href = this.protocolLink;
}, 1000);
}
}, 250);
}); });
this.checkAndRedirectToPwa(); this.checkAndRedirectToPwa();
@@ -222,9 +443,8 @@ export class AppComponent implements OnInit {
} }
async checkVersionSync(): Promise<boolean> { async checkVersionSync(): Promise<boolean> {
// Controlla SEMPRE version.json per primo — è il modo più affidabile per // Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW. // Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
// Su mobile, checkForUpdate() può essere lento o inaffidabile.
try { try {
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`); console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
const response = await Promise.race([ const response = await Promise.race([
@@ -234,10 +454,9 @@ export class AppComponent implements OnInit {
if (response && response.ok) { if (response && response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.version && data.version !== VERSION) { if (data && data.version && data.version !== VERSION) {
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Forza aggiornamento...`); console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Avvio aggiornamento automatico...`);
const overlay = showFullscreenUpdateOverlay();
// Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW) // Scarica e attiva il nuovo Service Worker se abilitato
if (this.swUpdate.isEnabled) { if (this.swUpdate.isEnabled) {
try { try {
const hasSwUpdate = await Promise.race([ const hasSwUpdate = await Promise.race([
@@ -245,7 +464,7 @@ export class AppComponent implements OnInit {
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000)) new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]); ]);
if (hasSwUpdate) { if (hasSwUpdate) {
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...'); console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...');
await Promise.race([ await Promise.race([
this.swUpdate.activateUpdate(), this.swUpdate.activateUpdate(),
new Promise<void>((resolve) => setTimeout(resolve, 5000)) new Promise<void>((resolve) => setTimeout(resolve, 5000))
@@ -256,7 +475,7 @@ export class AppComponent implements OnInit {
} }
} }
// Aggiorna anche la registrazione SW direttamente (doppia sicurezza) // Forziamo il controllo di aggiornamento della registrazione Service Worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
try { try {
const registration = await navigator.serviceWorker.ready; const registration = await navigator.serviceWorker.ready;
@@ -266,7 +485,7 @@ export class AppComponent implements OnInit {
} }
} }
// Disattiva service worker attivi per forzare il refresh completo // Deregistra i vecchi Service Worker per applicare la nuova versione pulita
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations(); const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) { for (const registration of registrations) {
@@ -274,7 +493,7 @@ export class AppComponent implements OnInit {
} }
} }
// Cancella le cache del browser // Pulisci le cache del browser
if ('caches' in window) { if ('caches' in window) {
const keys = await caches.keys(); const keys = await caches.keys();
for (const key of keys) { for (const key of keys) {
@@ -282,104 +501,34 @@ export class AppComponent implements OnInit {
} }
} }
overlay.finish(); // Ricarica la pagina in modo trasparente
// Ricarica con parametro cache-busting per forzare l'allineamento remoto
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set('update_cb', Date.now().toString()); url.searchParams.set('update_cb', Date.now().toString());
window.location.replace(url.toString()); window.location.replace(url.toString());
return true; return true;
} else { } else {
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.'); console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
return false;
} }
} }
} catch (e) { } catch (e) {
console.warn('[PWA-Update] version.json check fallito:', e); console.warn('[PWA-Update] version.json check fallito:', e);
} }
// 2. Fallback: Prova SwUpdate nel caso in cui il controllo version.json sia fallito o sia stato servito dalla cache
if (this.swUpdate.isEnabled) {
try {
console.log('[PWA-Update] Verifica aggiornamenti via SwUpdate all\'avvio...');
let activated = false;
let overlay: any = null;
const activateAndReload = async () => {
if (activated) return;
activated = true;
try {
await this.swUpdate.activateUpdate();
} catch (e) {
console.warn('[PWA-Update] activateUpdate fallito all\'avvio:', e);
}
// Deregistra i vecchi SW e cancella le cache per un ricaricamento pulito
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
}
}
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
if (overlay) overlay.finish();
setTimeout(() => {
const url = new URL(window.location.href);
url.searchParams.set('update_cb', Date.now().toString());
window.location.replace(url.toString());
}, 600);
};
// Sottoscrivi PRIMA di verificare l'aggiornamento per evitare race condition
const sub = this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio');
activateAndReload();
});
// Concedi fino a 8 secondi al controllo SW — le connessioni mobili possono essere lente
const hasUpdate = await Promise.race([
this.swUpdate.checkForUpdate(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]);
if (hasUpdate) {
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...');
overlay = showFullscreenUpdateOverlay();
// Timeout di sicurezza di 25 secondi: se VERSION_READY non arriva, attiva comunque
setTimeout(() => {
console.log('[PWA-Update] Safety timeout raggiunto all\'avvio, procedo...');
sub.unsubscribe();
activateAndReload();
}, 25000);
return true; // Attendi il ricaricamento
} else {
sub.unsubscribe();
}
} catch (err) {
console.warn('[PWA-Update] Controllo SwUpdate fallito all\'avvio:', err);
}
}
return false; return false;
} }
async checkAndRedirectToPwa() { async checkAndRedirectToPwa() {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('[AppComponent] Running on localhost - skipping PWA redirect and install overlays.');
this.checkLoaderDismissal();
return;
}
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone; const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) { if (isStandalone) {
localStorage.setItem('pwa-installed', 'true'); localStorage.setItem('pwa-installed', 'true');
this.checkLoaderDismissal();
return; return;
} }
@@ -387,34 +536,23 @@ export class AppComponent implements OnInit {
const path = window.location.pathname; const path = window.location.pathname;
this.protocolLink = `web+canti://open${path}${search}`; this.protocolLink = `web+canti://open${path}${search}`;
// Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo let isInstalled = false;
let isInstalled = localStorage.getItem('pwa-installed') === 'true'; if ('getInstalledRelatedApps' in navigator) {
if (!isInstalled && 'getInstalledRelatedApps' in navigator) {
try { try {
const relatedApps = await (navigator as any).getInstalledRelatedApps(); const relatedApps = await (navigator as any).getInstalledRelatedApps();
isInstalled = relatedApps.length > 0; isInstalled = relatedApps.length > 0;
if (isInstalled) {
localStorage.setItem('pwa-installed', 'true');
}
} catch (e) { } catch (e) {
console.warn('Failed to check installed apps:', e); console.warn('Failed to check installed apps:', e);
} }
} else {
isInstalled = localStorage.getItem('pwa-installed') === 'true';
} }
// Se non è rilevata in localStorage/relatedApps ed è Android o Desktop con supporto ai prompt: // Se prima era salvata come installata ma l'utente riceve il prima possibile un beforeinstallprompt,
// attendiamo 1.5s per dare tempo all'evento 'beforeinstallprompt' di scattare. // significa che l'app è stata disinstallata!
// Se non scatta, significa che l'app è già installata. if (this.settingsService.deferredPrompt() || this.settingsService.showInstallButton()) {
if (!isInstalled && !this.settingsService.isIos() && ('onbeforeinstallprompt' in window)) { isInstalled = false;
await new Promise(resolve => setTimeout(resolve, 1500)); localStorage.setItem('pwa-installed', 'false');
isInstalled = localStorage.getItem('pwa-installed') === 'true';
if (!isInstalled) {
const hasPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
if (!hasPrompt) {
console.log('[AppComponent] PWA detected as already installed (onbeforeinstallprompt supported but no prompt fired).');
isInstalled = true;
localStorage.setItem('pwa-installed', 'true');
}
}
} }
this.isPwaInstalled.set(isInstalled); this.isPwaInstalled.set(isInstalled);
@@ -553,6 +691,20 @@ export class AppComponent implements OnInit {
this.checkLoaderDismissal(); this.checkLoaderDismissal();
} }
} }
private exitApp() {
try {
App.exitApp();
} catch (e) {}
try {
if ((navigator as any)?.app?.exitApp) {
(navigator as any).app.exitApp();
}
} catch (e) {}
try {
window.close();
} catch (e) {}
}
} }
export function showFullscreenUpdateOverlay() { export function showFullscreenUpdateOverlay() {
@@ -601,3 +753,4 @@ export function showFullscreenUpdateOverlay() {
} }
}; };
} }
@@ -12,8 +12,12 @@
<zxing-scanner <zxing-scanner
[formats]="allowedFormats" [formats]="allowedFormats"
[device]="currentDevice" [device]="currentDevice"
[autostart]="true"
[tryHarder]="false"
(camerasFound)="onCamerasFound($event)" (camerasFound)="onCamerasFound($event)"
(scanSuccess)="onCodeResult($event)"> (scanSuccess)="onCodeResult($event)"
(scanError)="onScanError($event)"
(scanFailure)="onScanFailure($event)">
</zxing-scanner> </zxing-scanner>
<div class="scan-overlay"> <div class="scan-overlay">
@@ -55,6 +55,21 @@ export class QrScannerComponent {
} }
} }
onScanError(error: any) {
// Silenzia e ignora gli errori interni della libreria zxing
try {
if (error) {
// Previeni la propagazione a livello di window/console
if (typeof error.preventDefault === 'function') error.preventDefault();
if (typeof error.stopPropagation === 'function') error.stopPropagation();
}
} catch(e) {}
}
onScanFailure(failure: any) {
// Ignora i tentativi falliti di decodifica frame per frame
}
cancel() { cancel() {
this.modalCtrl.dismiss(); this.modalCtrl.dismiss();
} }
@@ -0,0 +1,41 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">Condividi Playlist</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="bg-gradient ion-padding">
<div class="qr-container">
<p class="description outfit-font">
Fai scansionare questo QR Code da un altro dispositivo per condividere all'istante la playlist <strong>{{ playlistName }}</strong>.
</p>
<div class="qr-card glass">
<div class="qr-wrapper" *ngIf="qrCodeUrl">
<img [src]="qrCodeUrl" alt="QR Code della Playlist" class="qr-image" />
</div>
<div class="playlist-box">
<span class="playlist-label outfit-font">Nome Playlist:</span>
<span class="playlist-text outfit-font">{{ playlistName }}</span>
</div>
</div>
<div class="actions-wrapper">
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="shareLinkNative()">
<ion-icon name="share-social-outline" slot="start"></ion-icon>
Invia Link / Condividi
</ion-button>
<ion-button expand="block" fill="outline" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
<ion-icon name="copy-outline" slot="start"></ion-icon>
Copia Link
</ion-button>
</div>
</div>
</ion-content>
@@ -0,0 +1,90 @@
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 8px;
text-align: center;
min-height: 100%;
}
.description {
font-size: 0.95rem;
line-height: 1.5;
color: var(--ion-text-color);
opacity: 0.9;
margin-bottom: 24px;
max-width: 320px;
}
.qr-card {
padding: 24px;
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 24px;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
}
.qr-wrapper {
background: white;
padding: 12px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 220px;
height: 220px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.playlist-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.playlist-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ion-color-secondary);
font-weight: 600;
}
.playlist-text {
font-size: 1rem;
color: var(--ion-text-color);
font-weight: bold;
word-break: break-word;
opacity: 0.95;
}
.actions-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 340px;
}
.action-btn {
margin: 0;
--border-radius: 14px;
font-weight: 600;
height: 48px;
}
@@ -0,0 +1,90 @@
import { Component, OnInit, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-share-playlist-qr-modal',
templateUrl: './share-playlist-qr-modal.component.html',
styleUrls: ['./share-playlist-qr-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule]
})
export class SharePlaylistQrModalComponent implements OnInit {
private modalCtrl = inject(ModalController);
private toastCtrl = inject(ToastController);
@Input() playlistName!: string;
@Input() shareLink!: string;
public qrCodeUrl: string = '';
ngOnInit() {
this.generateQr();
}
async generateQr() {
try {
this.qrCodeUrl = await QRCode.toDataURL(this.shareLink, {
errorCorrectionLevel: 'H',
margin: 2,
width: 400,
color: {
dark: '#1e293b',
light: '#ffffff'
}
});
} catch (err) {
console.error('Failed to generate QR Code:', err);
}
}
async shareLinkNative() {
const fileName = `${this.playlistName.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
const res = await fetch(this.qrCodeUrl);
const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' });
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla subito: ${this.shareLink}`
});
} else if (navigator.share) {
await navigator.share({
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla: ${this.shareLink}`
});
} else {
await this.copyToClipboard();
}
} catch (err) {
console.error('Share failed', err);
await this.copyToClipboard();
}
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.shareLink);
const toast = await this.toastCtrl.create({
message: 'Link copiato negli appunti!',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
} catch (err) {
console.error('Failed to copy text:', err);
}
}
dismiss() {
this.modalCtrl.dismiss();
}
}
+329 -153
View File
@@ -5,20 +5,26 @@
<img src="assets/icon/favicon.png" class="header-logo"> <img src="assets/icon/favicon.png" class="header-logo">
<div class="header-text-group"> <div class="header-text-group">
<span class="app-name">{{ appName }}</span> <span class="app-name">{{ appName }}</span>
<span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;"> <span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span> v{{ version }}
<span *ngIf="hasUpdateAvailable()" style="display: inline-flex; align-items: center; justify-content: center; padding: 4px; margin-left: 2px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 24px; height: 24px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3);">
<ion-icon name="refresh-outline" style="font-size: 1.15rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
</span> </span>
</div> </div>
</div> </div>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto" *ngIf="settingsService.showEditor()">
<ion-button (click)="importPlaylist()" class="add-btn"> <ion-icon slot="icon-only" name="add-outline"></ion-icon>
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button> </ion-button>
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
</ion-button>
</ng-container>
<ng-template #showQrBtn>
<ion-button (click)="importPlaylist()" class="add-btn">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button>
</ng-template>
<ion-button routerLink="/settings" class="settings-btn"> <ion-button routerLink="/settings" class="settings-btn">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon> <ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button> </ion-button>
@@ -32,110 +38,322 @@
placeholder="Cerca un canto..." placeholder="Cerca un canto..."
[value]="searchQuery()" [value]="searchQuery()"
(ionInput)="onSearch($event)" (ionInput)="onSearch($event)"
[disabled]="isAdvancedSearchOpen()"
class="custom-searchbar"> class="custom-searchbar">
</ion-searchbar> </ion-searchbar>
<ion-button fill="clear" (click)="toggleVoiceSearch()" class="voice-search-btn"> <ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
<ion-icon slot="icon-only" [name]="audioEngine.isSearching() ? 'mic' : 'mic-outline'" [color]="audioEngine.isSearching() ? 'danger' : 'secondary'"></ion-icon> <ion-icon slot="icon-only" name="options-outline" [color]="isAdvancedSearchOpen() ? 'secondary' : 'medium'"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleVoiceSearch('global')" class="voice-search-btn" [disabled]="isAdvancedSearchOpen()">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'danger' : 'secondary'"></ion-icon>
</ion-button> </ion-button>
</div> </div>
</div> </div>
<div class="filter-actions-row"> <!-- Advanced Search & Filters Toggle Row -->
<div class="song-count-card outfit-font"> <div class="advanced-search-toggle-row">
{{ filteredCanti().length }} <div class="toggle-row-left">
</div> <div class="song-count-card outfit-font">
<div class="filter-buttons"> {{ filteredCanti().length }}
<ion-button </div>
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'" <button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
size="small" <ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
(click)="toggleFilterType('playlist')" <span>Ricerche</span>
class="filter-chip"> <span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }} <ion-icon name="close-circle"></ion-icon>
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span> </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 -->
<ion-button <div class="advanced-search-panel glass outfit-font"
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'" *ngIf="isAdvancedSearchOpen()"
size="small" (touchstart)="onPanelTouchStart($event)"
(click)="toggleComunitaFilter()" (touchend)="onPanelTouchEnd($event, 'advancedSearch')">
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'" <div class="adv-search-grid">
class="filter-chip" <div class="adv-input-group">
style="margin: 0;"> <label class="adv-label">Ricerca per titolo</label>
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon> <div class="adv-input-wrapper">
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }} <ion-icon name="text-outline" class="adv-input-icon"></ion-icon>
</ion-button> <input
type="text"
placeholder="Titolo canto..."
[value]="searchTitle()"
(input)="onSearchTitleInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchTitle()" (click)="searchTitle.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('title')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div> </div>
<ion-button <div class="adv-input-group">
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'" <label class="adv-label">Ricerca per autore</label>
size="small" <div class="adv-input-wrapper">
(click)="toggleFilterType('lista_completa')" <ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
class="filter-chip"> <input
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }} type="text"
<ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon> placeholder="Nome autore..."
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)"> [value]="searchAuthor()"
<ion-icon slot="end" name="close-circle"></ion-icon> (input)="onSearchAuthorInput($event)"
</span> class="adv-input" />
</ion-button> <ion-icon name="close-circle" *ngIf="searchAuthor()" (click)="searchAuthor.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('author')" class="adv-mic-btn">
<ion-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>
<ion-button <div class="adv-input-group">
[fill]="activeFilterType() === 'liturgico' || selectedLiturgico() !== null ? 'solid' : 'outline'" <label class="adv-label">Ricerca per testo</label>
size="small" <div class="adv-input-wrapper">
(click)="toggleFilterType('liturgico')" <ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
class="filter-chip"> <input
{{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }} type="text"
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon> placeholder="Parole nel testo..."
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)"> [value]="searchText()"
<ion-icon slot="end" name="close-circle"></ion-icon> (input)="onSearchTextInput($event)"
</span> class="adv-input" />
</ion-button> <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>
<ion-button <div class="adv-input-group">
[fill]="activeFilterType() === 'tematico' || selectedTematico() !== null ? 'solid' : 'outline'" <label class="adv-label">Ricerca per nr canto</label>
size="small" <div class="adv-input-wrapper">
(click)="toggleFilterType('tematico')" <ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
class="filter-chip"> <input
{{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }} type="text"
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon> placeholder="Numero canto..."
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)"> [value]="searchNumber()"
<ion-icon slot="end" name="close-circle"></ion-icon> (input)="onSearchNumberInput($event)"
</span> class="adv-input" />
</ion-button> <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>
<ion-button <!-- Playlist Card (default closed) -->
[fill]="showSuggeriti() ? 'solid' : 'outline'" <div class="filter-card glass outfit-font"
size="small" *ngIf="isPlaylistCardOpen()"
(click)="toggleSuggeriti()" (touchstart)="onPanelTouchStart($event)"
class="filter-chip"> (touchend)="onPanelTouchEnd($event, 'playlistCard')">
Suggeriti <div class="filter-card-row">
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)"> <span class="filter-row-label">Playlist</span>
<ion-icon slot="end" name="close-circle"></ion-icon> <div class="filter-row-items">
</span> <!-- Comunità filter button if enabled -->
</ion-button> <div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<ion-button
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
size="small"
(click)="toggleComunitaFilter()"
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
class="filter-chip"
style="margin: 0;">
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
</ion-button>
</div>
<ion-button <!-- Playlist Chips -->
[fill]="showTopTen() ? 'solid' : 'outline'" <div
size="small" *ngFor="let item of playlistService.allPlaylists()"
(click)="toggleTopTen()" class="momento-chip glass"
class="filter-chip"> [class.active]="playlistService.activePlaylistId() === item.id"
Top Ten [class.comunita-chip]="item.isComunita"
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)"> (click)="playlistService.activePlaylistId() === item.id ? clearSpecialList($event) : selectPlaylist(item)">
<ion-icon slot="end" name="close-circle"></ion-icon> <ion-icon *ngIf="item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ item.name }}
<span *ngIf="playlistService.activePlaylistId() === item.id && isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 18px; height: 18px; vertical-align: middle;">
<ion-icon name="refresh-outline" style="font-size: 0.85rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
<span class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
<span *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
Nessuna playlist creata
</span> </span>
</ion-button> </div>
</div>
</div>
<!-- Filter Card (default closed) -->
<div class="filter-card glass outfit-font"
*ngIf="isFilterCardOpen()"
(touchstart)="onPanelTouchStart($event)"
(touchend)="onPanelTouchEnd($event, 'filterCard')">
<!-- Row 2: Tipologia -->
<div class="filter-card-row">
<span class="filter-row-label">Tipologia</span>
<div class="filter-row-items">
<div
class="momento-chip glass"
[class.active]="showValidati()"
(click)="toggleValidati()">
Validati
<span class="close-icon-wrapper" *ngIf="showValidati()" (click)="clearListaCompleta($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
<div
class="momento-chip glass"
[class.active]="showNonValidati()"
(click)="toggleNonValidati()"
style="display: inline-flex; align-items: center; gap: 6px;">
<span>Non Validati</span>
<ion-icon
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
name="add-outline"
(click)="$event.stopPropagation()"
routerLink="/propose-canto"
class="non-validati-add-icon">
</ion-icon>
<span class="close-icon-wrapper" *ngIf="showNonValidati()" (click)="clearListaCompleta($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
</div>
</div>
<!-- Row 3: Liturgia -->
<div class="filter-card-row">
<span class="filter-row-label">Liturgia</span>
<div class="filter-row-items">
<div
*ngFor="let item of cantiService.indiceLiturgico()"
class="momento-chip glass"
[class.active]="selectedLiturgico() === item.id"
(click)="toggleIndex(item.id, 'liturgico')">
{{ item.tag_name }}
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() === item.id" (click)="clearLiturgico($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
</div>
</div>
<!-- Row 4: Periodo -->
<div class="filter-card-row">
<span class="filter-row-label">Periodo</span>
<div class="filter-row-items">
<div
*ngFor="let item of cantiService.indiceTematico()"
class="momento-chip glass"
[class.active]="selectedTematico() === item.id"
(click)="toggleIndex(item.id, 'tematico')">
{{ item.tag_name }}
<span class="close-icon-wrapper" *ngIf="selectedTematico() === item.id" (click)="clearTematico($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</div>
</div>
</div>
<!-- Row 5 (Footer): Suggeriti, Top Ten & Reset filtri -->
<div class="filter-card-footer">
<div class="footer-actions">
<ion-button
[fill]="showSuggeriti() ? 'solid' : 'outline'"
size="small"
(click)="toggleSuggeriti()"
class="filter-chip">
Suggeriti
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="showTopTen() ? 'solid' : 'outline'"
size="small"
(click)="toggleTopTen()"
class="filter-chip">
Top Ten
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="showOnlyMine() ? 'solid' : 'outline'"
size="small"
(click)="toggleOnlyMine()"
class="filter-chip">
Miei
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
</div>
</div> </div>
</div> </div>
</div> </div>
</ion-toolbar> </ion-toolbar>
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
<div class="playlist-actions-bar">
<!-- Duration Badge on the left -->
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
<ion-icon name="time-outline"></ion-icon>
<span>{{ totalPlaylistDuration() }}</span>
</div>
<!-- Playlist Name in middle -->
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
</div>
<!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button>
</div>
<!-- Category Scroll (Dropdown style) -->
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="shouldShowSubSectionToolbar()">
<div class="momento-scroll" *ngIf="activeFilterType() !== 'playlist' || playlistService.allPlaylists().length > 0 || playlistService.selectionMode()">
<!-- Selection Mode Actions --> <!-- Selection Mode Actions -->
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0"> <div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
<span class="selection-count">{{ reorderList().length }}</span> <span class="selection-count">{{ reorderList().length }}</span>
@@ -156,65 +374,6 @@
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon> <ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
</ion-button> </ion-button>
</div> </div>
<!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="activeFilterType() === 'playlist' && playlistService.activePlaylistId() && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="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)="shareActivePlaylist()" class="mini-action-btn">
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button>
</div>
<!-- Filter chips -->
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
<div
class="momento-chip glass"
[class.active]="showValidati()"
(click)="toggleValidati()">
Validati
</div>
<div
class="momento-chip glass"
[class.active]="showNonValidati()"
(click)="toggleNonValidati()"
style="display: inline-flex; align-items: center; gap: 6px;">
<span>Non Validati</span>
<ion-icon
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
name="add-outline"
(click)="$event.stopPropagation()"
routerLink="/propose-canto"
class="non-validati-add-icon">
</ion-icon>
</div>
</ng-container>
<ng-container *ngIf="activeFilterType() !== 'lista_completa'">
<div
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
class="momento-chip glass"
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
</div>
</ng-container>
</div>
<!-- Instructions when playlist is empty -->
<div class="empty-playlist-container" *ngIf="activeFilterType() === 'playlist' && playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()">
<p class="empty-playlist-instruction outfit-font">
Per creare una playlist, seleziona il nr del canto che vuoi inserire nella playlist, riordinali e salvala con nome
</p>
</div> </div>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
@@ -231,6 +390,7 @@
<div class="loading-wrapper"> <div class="loading-wrapper">
<ion-spinner name="crescent" color="secondary"></ion-spinner> <ion-spinner name="crescent" color="secondary"></ion-spinner>
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div> <div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
<ion-progress-bar [value]="cantiService.progress() / 100" color="secondary" style="width: 200px; border-radius: 10px; height: 8px; margin-top: 8px;"></ion-progress-bar>
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p> <p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
</div> </div>
</div> </div>
@@ -311,6 +471,23 @@
</h2> </h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span>{{ canto.autore || 'Autore sconosciuto' }}</span> <span>{{ canto.autore || 'Autore sconosciuto' }}</span>
</p>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px;"
*ngIf="(settingsService.showDurationBpmTonality() && (canto.durata || canto.bpm || getSongTonality(canto))) || (settingsService.showUpdateDate() && canto.data_update) || (showTopTen() && getEsecuzioniCount(canto.id) !== null) || (showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null)">
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
<span *ngIf="canto.durata" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="time-outline" style="font-size: 0.75rem;"></ion-icon>
{{ canto.durata }}
</span>
<span *ngIf="canto.bpm" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="pulse-outline" style="font-size: 0.75rem;"></ion-icon>
{{ canto.bpm }} BPM
</span>
<span *ngIf="getSongTonality(canto)" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="musical-note-outline" style="font-size: 0.75rem;"></ion-icon>
{{ getSongTonality(canto) }}
</span>
</ng-container>
<span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;"> <span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;">
<ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon> <ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon>
agg. {{ cantiService.formatUpdateDate(canto.data_update) }} agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
@@ -362,8 +539,8 @@
</div> </div>
</div> </div>
<!-- Delete Button (only for local songs) --> <!-- Delete Button (only for "mio" songs when "Miei" filter is active) -->
<div *ngIf="canto.id.startsWith('my_')" class="delete-section" (click)="$event.stopPropagation()"> <div *ngIf="settingsService.showEditor() && canto.id.startsWith('my_') && showOnlyMine()" class="delete-section" (click)="$event.stopPropagation()">
<ion-button fill="clear" color="danger" (click)="deleteMyCanto(canto.id, $event)"> <ion-button fill="clear" color="danger" (click)="deleteMyCanto(canto.id, $event)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon> <ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-button> </ion-button>
@@ -443,7 +620,6 @@
<div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)"> <div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)">
<div class="black-screen-content" (click)="$event.stopPropagation()"> <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%;"> <div (click)="isBlackOverlayDismissed.set(true)" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
<ion-icon name="car-outline" class="car-mode-icon"></ion-icon>
<p class="car-mode-text outfit-font">Schermo nero attivo</p> <p class="car-mode-text outfit-font">Schermo nero attivo</p>
</div> </div>
+479 -77
View File
@@ -48,95 +48,447 @@
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
.voice-search-btn { .adv-search-btn, .voice-search-btn {
--padding-start: 8px; --padding-start: 6px;
--padding-end: 8px; --padding-end: 6px;
margin: 0; margin: 0;
height: 44px; height: 44px;
ion-icon { ion-icon {
font-size: 1.5rem; font-size: 1.4rem;
} }
} }
.adv-search-btn.active {
opacity: 1;
}
} }
.filter-actions-row { .advanced-search-toggle-row {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; justify-content: space-between;
overflow-x: auto; padding: 0 2px;
gap: 12px; gap: 4px;
padding: 8px 4px 8px 0; flex-wrap: nowrap;
// Hide scrollbar but keep functionality .toggle-row-left {
&::-webkit-scrollbar { display: flex;
display: none; align-items: center;
gap: 8px;
flex-wrap: nowrap;
width: 100%;
justify-content: flex-start;
} }
-ms-overflow-style: none;
scrollbar-width: none;
.song-count-card { .song-count-card {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(var(--ion-color-secondary-rgb), 0.15); background: rgba(var(--ion-color-secondary-rgb), 0.15);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
padding: 0 12px; padding: 0 8px;
border-radius: 12px; border-radius: 12px;
height: 32px; height: 30px;
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 800; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
.filter-buttons { .advanced-search-link, .clear-advanced-link {
display: flex; background: transparent;
gap: 8px; border: none;
outline: none;
color: var(--ion-color-secondary);
font-size: 0.8rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
padding: 4px 5px;
border-radius: 8px;
transition: all 0.2s ease;
opacity: 0.9;
white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
ion-button.filter-chip { &:hover, &:active {
--border-radius: 20px; opacity: 1;
--border-width: 1px; background: rgba(var(--ion-color-secondary-rgb), 0.12);
font-family: 'Outfit', sans-serif; }
font-weight: 500;
margin: 0;
min-height: 32px;
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
.close-icon-wrapper { ion-icon {
display: inline-flex; font-size: 1rem;
align-items: center; }
justify-content: center;
margin-left: 6px; .inline-clear-btn {
padding: 4px; display: inline-flex;
margin-right: -8px; align-items: center;
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);
}
}
}
.advanced-search-panel {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 12px 14px;
backdrop-filter: blur(12px);
animation: advSearchFadeIn 0.25s ease-out;
.adv-search-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.adv-input-group {
display: flex;
flex-direction: column;
gap: 4px;
.adv-label {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-left: 2px;
}
.adv-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 0 10px;
height: 38px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus-within {
border-color: var(--ion-color-secondary);
box-shadow: 0 0 0 2px rgba(var(--ion-color-secondary-rgb), 0.2);
}
.adv-input-icon {
font-size: 1.1rem;
color: var(--ion-color-secondary);
margin-right: 8px;
flex-shrink: 0;
}
.adv-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #ffffff;
font-size: 0.88rem;
font-family: inherit;
width: 100%;
&::placeholder {
color: rgba(255, 255, 255, 0.35);
}
}
.adv-clear-icon {
font-size: 1rem;
color: rgba(255, 255, 255, 0.4);
cursor: pointer; cursor: pointer;
z-index: 100; 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 { ion-icon {
font-size: 1.2rem; font-size: 1.15rem;
margin: 0;
pointer-events: none;
}
&:active {
opacity: 0.5;
transform: scale(0.9);
} }
} }
} }
}
}
@keyframes advSearchFadeIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:host-context(body.high-contrast) {
.advanced-search-link {
color: var(--ion-color-secondary) !important;
}
.clear-advanced-link {
color: var(--ion-color-danger, #d32f2f) !important;
}
.advanced-search-panel {
background: #ffffff !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
.adv-input-group {
.adv-label {
color: var(--ion-color-secondary, #d96b00) !important;
font-weight: 700 !important;
}
.adv-input-wrapper {
background: #f4f5f8 !important;
border: 1px solid #c0c4cc !important;
&:focus-within {
border-color: var(--ion-color-secondary, #d96b00) !important;
box-shadow: 0 0 0 2px rgba(217, 107, 0, 0.25) !important;
}
.adv-input-icon {
color: var(--ion-color-secondary, #d96b00) !important;
}
.adv-input {
color: #000000 !important;
font-weight: 600 !important;
&::placeholder {
color: #666666 !important;
font-weight: 400 !important;
}
}
.adv-clear-icon {
color: #666666 !important;
&:hover {
color: #000000 !important;
}
}
}
}
}
}
.filter-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
backdrop-filter: blur(12px);
margin-top: 4px;
.filter-card-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 34px;
.filter-row-label {
font-weight: 700;
font-size: 0.8rem;
color: var(--ion-color-secondary);
width: 68px;
flex-shrink: 0;
text-transform: capitalize;
letter-spacing: 0.3px;
opacity: 0.9;
}
.filter-row-items {
display: flex;
gap: 6px;
overflow-x: auto;
flex: 1;
align-items: center;
padding-bottom: 2px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
.empty-playlist-text {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
}
}
.filter-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
.song-count-card {
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--ion-color-secondary-rgb), 0.15);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
padding: 0 12px;
border-radius: 12px;
height: 32px;
font-size: 0.82rem;
font-weight: 700;
color: var(--ion-color-secondary);
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.footer-actions {
display: flex;
align-items: center;
gap: 8px;
ion-button.filter-chip {
--border-radius: 20px;
--border-width: 1px;
font-family: 'Outfit', sans-serif;
font-weight: 500;
margin: 0;
min-height: 32px;
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
.close-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 4px;
margin-right: -8px;
cursor: pointer;
ion-icon {
font-size: 1.1rem;
margin: 0;
pointer-events: none;
}
}
}
}
} }
} }
ion-toolbar.playlist-toolbar {
--padding-top: 0px;
--padding-bottom: 8px;
--padding-start: 16px;
--padding-end: 16px;
--min-height: auto;
}
.playlist-actions-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 2px;
margin-bottom: 0px;
.playlist-duration-pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(var(--ion-color-secondary-rgb), 0.15);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
padding: 0 10px;
border-radius: 12px;
height: 30px;
font-size: 0.82rem;
font-weight: 700;
color: var(--ion-color-secondary);
backdrop-filter: blur(10px);
flex-shrink: 0;
ion-icon {
font-size: 0.95rem;
}
}
.playlist-title-badge {
display: inline-flex;
align-items: center;
padding: 0 10px;
height: 30px;
font-size: 0.9rem;
font-weight: 700;
color: var(--ion-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 50%;
margin: 0 8px;
flex-shrink: 1;
.playlist-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.selection-pill {
margin: 0;
margin-left: auto;
}
}
.transparent-list { .transparent-list {
background: transparent !important; background: transparent !important;
padding-bottom: 120px; // Spazio per il player fisso padding-bottom: 120px; // Spazio per il player fisso
@@ -160,28 +512,87 @@ ion-title {
letter-spacing: 1px; letter-spacing: 1px;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
padding-inline: 0; padding-inline: 0;
.add-btn, .settings-btn {
--color: var(--ion-color-secondary);
--padding-start: 8px;
--padding-end: 8px;
font-size: 1.2rem;
} }
ion-header {
padding-top: 6px;
}
ion-buttons[slot="end"] {
gap: 0px;
}
.update-btn, .add-btn, .settings-btn {
--color: var(--ion-color-secondary);
--padding-start: 0;
--padding-end: 0;
margin-left: -4px;
margin-right: 0;
width: 38px;
min-width: 38px;
height: 38px;
ion-icon {
font-size: 1.6rem;
}
}
.update-btn {
animation: pulse-update 2.5s infinite ease-in-out;
}
@keyframes pulse-update {
0% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.1);
opacity: 1;
filter: drop-shadow(0 0 6px rgba(var(--ion-color-secondary-rgb), 0.6));
}
100% {
transform: scale(1);
opacity: 0.9;
}
}
@media (max-width: 375px) {
ion-buttons[slot="end"] {
gap: 0px;
}
.update-btn, .add-btn, .settings-btn {
--padding-start: 0;
--padding-end: 0;
margin-left: -5px;
width: 34px;
min-width: 34px;
height: 34px;
ion-icon {
font-size: 1.4rem;
}
}
.settings-btn {
margin-right: 2px;
}
}
ion-title {
.header-logo-wrapper { .header-logo-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 12px; gap: 8px;
padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room padding: 10px 0 10px 8px;
} }
.header-logo { .header-logo {
width: 42px; width: 38px;
height: 42px; height: 38px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2); border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2);
flex-shrink: 0;
} }
.header-text-group { .header-text-group {
@@ -192,24 +603,26 @@ ion-title {
line-height: 1; line-height: 1;
.app-name { .app-name {
font-size: 1.4rem; font-size: clamp(1.1rem, 3.8vw, 1.25rem);
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
margin-bottom: 2px; margin-bottom: 2px;
white-space: nowrap;
} }
.version-badge { .version-badge {
font-size: 0.8rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px; letter-spacing: 0.5px;
white-space: nowrap;
} }
} }
} }
.settings-btn { .settings-btn {
margin-right: 16px; margin-right: 8px;
} }
.special-list-banner { .special-list-banner {
@@ -775,25 +1188,14 @@ ion-title {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
.play-btn { .play-btn, .skip-btn, .close-btn {
--padding-start: 0; --padding-start: 0;
--padding-end: 0; --padding-end: 0;
height: 44px; height: 40px;
width: 44px; width: 40px;
ion-icon { ion-icon {
font-size: 2.2rem; font-size: 1.6rem;
}
}
.skip-btn, .close-btn {
--padding-start: 0;
--padding-end: 0;
height: 36px;
width: 36px;
ion-icon {
font-size: 1.4rem;
} }
} }
+491 -34
View File
@@ -19,6 +19,7 @@ import { environment } from '../../environments/environment';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators'; import { filter, first } from 'rxjs/operators';
import { showFullscreenUpdateOverlay } from '../app.component'; import { showFullscreenUpdateOverlay } from '../app.component';
import { LyricsParserService } from '../services/lyrics-parser.service';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@@ -50,6 +51,18 @@ export class HomePage implements OnDestroy {
} }
public searchQuery = signal<string>(''); public searchQuery = signal<string>('');
public isAdvancedSearchOpen = signal<boolean>(false);
public isFilterCardOpen = signal<boolean>(false);
public isPlaylistCardOpen = signal<boolean>(false);
public searchTitle = signal<string>('');
public searchAuthor = signal<string>('');
public searchText = signal<string>('');
public searchNumber = signal<string>('');
public activeVoiceField = signal<'global' | 'title' | 'author' | 'text' | 'number'>('global');
public hasAdvancedSearchParams = computed(() => {
return !!(this.searchTitle().trim() || this.searchAuthor().trim() || this.searchText().trim() || this.searchNumber().trim());
});
public selectedLiturgico = signal<number | null>(null); public selectedLiturgico = signal<number | null>(null);
public selectedTematico = signal<number | null>(null); public selectedTematico = signal<number | null>(null);
public showOnlyMine = signal<boolean>(false); public showOnlyMine = signal<boolean>(false);
@@ -57,6 +70,25 @@ export class HomePage implements OnDestroy {
public showSuggeriti = signal<boolean>(false); public showSuggeriti = signal<boolean>(false);
public showValidati = signal<boolean>(false); public showValidati = signal<boolean>(false);
public showNonValidati = signal<boolean>(false); public showNonValidati = signal<boolean>(false);
public hasPlaylistCardParams = computed(() => {
return !!(
this.playlistService.activePlaylistId() !== null ||
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
);
});
public hasFilterCardParams = computed(() => {
return !!(
this.selectedLiturgico() !== null ||
this.selectedTematico() !== null ||
this.showOnlyMine() ||
this.showTopTen() ||
this.showSuggeriti() ||
this.showValidati() ||
this.showNonValidati()
);
});
public isMassCardExpanded = signal<boolean>(false); public isMassCardExpanded = signal<boolean>(false);
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null); public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
public loadedThumbs = new Set<string>(); public loadedThumbs = new Set<string>();
@@ -93,6 +125,7 @@ export class HomePage implements OnDestroy {
public playlistService = inject(PlaylistService); public playlistService = inject(PlaylistService);
public settingsService = inject(SettingsService); public settingsService = inject(SettingsService);
public cantiLettureService = inject(CantiLettureService); public cantiLettureService = inject(CantiLettureService);
public lyricsParser = inject(LyricsParserService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private modalCtrl = inject(ModalController); private modalCtrl = inject(ModalController);
@@ -203,6 +236,24 @@ export class HomePage implements OnDestroy {
} catch (e) { } catch (e) {
console.warn('[PWA-Update] activateUpdate failed:', e); console.warn('[PWA-Update] activateUpdate failed:', e);
} }
try {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
}
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
} catch (e) {
console.warn('[PWA-Update] Cleanup failed:', e);
}
overlay.finish(); overlay.finish();
setTimeout(() => { setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
@@ -237,17 +288,12 @@ export class HomePage implements OnDestroy {
private async checkUpdateStatus() { private async checkUpdateStatus() {
try { try {
let swFoundUpdate = false;
if (this.swUpdate.isEnabled) { if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false); swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
if (swFoundUpdate) {
this.hasUpdateAvailable.set(true);
return;
}
} }
const mismatch = await this.checkVersionJson(); const mismatch = await this.checkVersionJson();
if (mismatch) { this.hasUpdateAvailable.set(swFoundUpdate || mismatch);
this.hasUpdateAvailable.set(true);
}
} catch (err) { } catch (err) {
console.warn('Failed to check for updates on startup:', err); console.warn('Failed to check for updates on startup:', err);
} }
@@ -377,7 +423,7 @@ export class HomePage implements OnDestroy {
// Filter by Validati (non personali e non contrassegnati come non validati) // Filter by Validati (non personali e non contrassegnati come non validati)
if (this.showValidati()) { if (this.showValidati()) {
list = list.filter(c => !c.nonValidato && !c.id.startsWith('my_')); list = list.filter(c => !c.nonValidato && (onlyMine || !c.id.startsWith('my_')));
} }
// Filter by Non-Validati (personali o esplicitamente non validati) // Filter by Non-Validati (personali o esplicitamente non validati)
@@ -413,36 +459,85 @@ export class HomePage implements OnDestroy {
return commNum ? commNum === targetId : c.id_canti.toString() === targetId; return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
}); });
} else { } else {
const tokens = query.split(/\s+/).filter(t => t.length > 0);
list = list.filter(c => { list = list.filter(c => {
const titleMatch = this.normalize(c.titolo).includes(query); const normTitle = this.normalize(c.titolo);
const authorMatch = this.normalize(c.autore).includes(query); const normAuthor = this.normalize(c.autore);
const commNum = this.getCommunitySongNumber(c); const commNum = this.getCommunitySongNumber(c);
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query); const songNum = commNum || c.id_canti.toString();
const lyricsPlain = (c.testo || '') const lyricsPlain = (c.testo || '')
.replace(/{.*?}/g, '') .replace(/{.*?}/g, '')
.replace(/\n/g, ' '); .replace(/\n/g, ' ');
const lyricsMatch = this.normalize(lyricsPlain).includes(query); const normLyrics = this.normalize(lyricsPlain);
return titleMatch || authorMatch || lyricsMatch || numberMatch; // Full string matches in title, author, lyrics, or song number
const titleMatch = normTitle.includes(query);
const authorMatch = normAuthor.includes(query);
const lyricsMatch = normLyrics.includes(query);
const numberMatch = songNum.includes(query);
if (titleMatch || authorMatch || lyricsMatch || numberMatch) {
return true;
}
// Multi-word token match across fields (e.g. title word + author word)
if (tokens.length > 1) {
return tokens.every(token =>
normTitle.includes(token) ||
normAuthor.includes(token) ||
normLyrics.includes(token) ||
songNum.includes(token)
);
}
return false;
}); });
} }
} }
// Advanced search filters (titolo, autore, testo, nr canto)
const advTitle = this.normalize(this.searchTitle()).trim();
const advAuthor = this.normalize(this.searchAuthor()).trim();
const advText = this.normalize(this.searchText()).trim();
const advNumber = this.normalize(this.searchNumber()).trim();
if (advTitle) {
list = list.filter(c => this.normalize(c.titolo).includes(advTitle));
}
if (advAuthor) {
list = list.filter(c => this.normalize(c.autore || '').includes(advAuthor));
}
if (advText) {
list = list.filter(c => {
const lyricsPlain = (c.testo || '')
.replace(/{.*?}/g, '')
.replace(/\n/g, ' ');
return this.normalize(lyricsPlain).includes(advText);
});
}
if (advNumber) {
list = list.filter(c => {
const commNum = this.getCommunitySongNumber(c);
const songNum = (commNum ? commNum.toString() : c.id_canti.toString()).toLowerCase().trim();
return songNum === advNumber.toLowerCase().trim();
});
}
// G. Sorting/Ordering // G. Sorting/Ordering
if (topTen) { if (topTen) {
const eseguiti = this.cantiService.cantiEseguiti(); list = [...list].sort((a, b) => {
const eseguitiMap = new Map<number, number>(); const numA = this.getEsecuzioniCount(a.id) || 0;
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num)); const numB = this.getEsecuzioniCount(b.id) || 0;
list = list.sort((a, b) => {
const numA = eseguitiMap.get(a.id_canti) || 0;
const numB = eseguitiMap.get(b.id_canti) || 0;
return numB - numA; return numB - numA;
}); });
} else if (suggeriti) { } else if (suggeriti) {
const suggMap = this.cantiLettureService.suggestionsMap(); const suggMap = this.cantiLettureService.suggestionsMap();
list = list.sort((a, b) => { list = [...list].sort((a, b) => {
const pesoA = suggMap.get(a.id_canti) || 0; const pesoA = suggMap.get(a.id_canti) || 0;
const pesoB = suggMap.get(b.id_canti) || 0; const pesoB = suggMap.get(b.id_canti) || 0;
return pesoB - pesoA; return pesoB - pesoA;
@@ -462,6 +557,63 @@ export class HomePage implements OnDestroy {
return this.filteredCanti().slice(0, this.limit()); return this.filteredCanti().slice(0, this.limit());
}); });
public hasAnyFilter = computed(() => {
return (
this.playlistService.activeListName() !== null ||
this.selectedLiturgico() !== null ||
this.selectedTematico() !== null ||
this.showOnlyMine() ||
this.showTopTen() ||
this.showSuggeriti() ||
this.showValidati() ||
this.showNonValidati() ||
!!this.searchQuery().trim() ||
this.hasAdvancedSearchParams() ||
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
);
});
public totalPlaylistDuration = computed(() => {
if (this.playlistService.activePlaylistId() === null) return '';
const songs = this.filteredCanti();
if (songs.length === 0) return '';
let totalSeconds = 0;
for (const song of songs) {
if (song.durata) {
totalSeconds += this.parseDuration(song.durata);
}
}
if (totalSeconds === 0) return '';
return this.formatTotalDuration(totalSeconds);
});
private parseDuration(dur: string): number {
if (!dur) return 0;
const parts = dur.split(':').map(Number);
if (parts.some(isNaN)) return 0;
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
} else if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return 0;
}
private formatTotalDuration(seconds: number): string {
const hh = Math.floor(seconds / 3600);
const mm = Math.floor((seconds % 3600) / 60);
const ss = seconds % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
if (hh > 0) {
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
return `${pad(mm)}:${pad(ss)}`;
}
constructor() { constructor() {
// Check if there is an update available // Check if there is an update available
this.checkUpdateStatus(); this.checkUpdateStatus();
@@ -476,8 +628,15 @@ export class HomePage implements OnDestroy {
// Polling setup: check for updates every 30 seconds // Polling setup: check for updates every 30 seconds
this.updatePollInterval = setInterval(() => { this.updatePollInterval = setInterval(() => {
this.checkUpdateStatus(); this.checkUpdateStatus();
if (this.isRemotePlaylist()) {
this.playlistService.checkForRemotePlaylistUpdates();
}
}, 30000); }, 30000);
// Sync homepage filtered canti IDs to playlistService.filteredListIds
effect(() => {
const ids = this.filteredCanti().map(c => c.id);
this.playlistService.filteredListIds.set(ids);
}, { allowSignalWrites: true });
// Track initial community filter state to avoid clearing during the initial run of the effect // Track initial community filter state to avoid clearing during the initial run of the effect
@@ -509,11 +668,29 @@ export class HomePage implements OnDestroy {
} }
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
// Sync speech recognition results to search query // Sync speech recognition results to search query or advanced field
effect(() => { effect(() => {
const transcript = this.audioEngine.searchTranscript(); const transcript = this.audioEngine.searchTranscript();
if (transcript) { if (transcript) {
this.searchQuery.set(transcript); const target = this.activeVoiceField();
switch (target) {
case 'title':
this.searchTitle.set(transcript);
break;
case 'author':
this.searchAuthor.set(transcript);
break;
case 'text':
this.searchText.set(transcript);
break;
case 'number':
this.searchNumber.set(transcript);
break;
case 'global':
default:
this.searchQuery.set(transcript);
break;
}
} }
}); });
@@ -631,6 +808,15 @@ export class HomePage implements OnDestroy {
}); });
} }
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);
}
}
async handleImport(base64: string) { async handleImport(base64: string) {
@@ -781,7 +967,9 @@ export class HomePage implements OnDestroy {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
link_youtube: item.link_youtube || '', link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
nonValidato: true nonValidato: true,
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
}; };
await this.playlistService.saveRemoteShareCanto(canto); await this.playlistService.saveRemoteShareCanto(canto);
@@ -839,7 +1027,9 @@ export class HomePage implements OnDestroy {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
})); }));
// 2. Reconstruct playlists // 2. Reconstruct playlists
@@ -884,6 +1074,7 @@ export class HomePage implements OnDestroy {
// Select it immediately // Select it immediately
this.selectPlaylist(selectedPl); this.selectPlaylist(selectedPl);
this.playlistService.lastPlaylist.set(selectedPl);
this.activeFilterType.set('playlist'); this.activeFilterType.set('playlist');
await loading.dismiss(); await loading.dismiss();
@@ -954,7 +1145,9 @@ export class HomePage implements OnDestroy {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
})); }));
if (customSongs.length > 0) { if (customSongs.length > 0) {
@@ -1036,6 +1229,95 @@ export class HomePage implements OnDestroy {
this.limit.set(30); this.limit.set(30);
} }
toggleAdvancedSearch() {
const willOpen = !this.isAdvancedSearchOpen();
this.isAdvancedSearchOpen.set(willOpen);
if (willOpen) {
this.isFilterCardOpen.set(false);
this.isPlaylistCardOpen.set(false);
}
}
toggleFilterCard() {
const willOpen = !this.isFilterCardOpen();
this.isFilterCardOpen.set(willOpen);
if (willOpen) {
this.isAdvancedSearchOpen.set(false);
this.isPlaylistCardOpen.set(false);
}
}
togglePlaylistCard() {
const willOpen = !this.isPlaylistCardOpen();
this.isPlaylistCardOpen.set(willOpen);
if (willOpen) {
this.isAdvancedSearchOpen.set(false);
this.isFilterCardOpen.set(false);
}
}
clearAdvancedSearch(event?: Event) {
if (event) {
event.stopPropagation();
}
this.searchTitle.set('');
this.searchAuthor.set('');
this.searchText.set('');
this.searchNumber.set('');
this.limit.set(30);
}
clearFilterCard(event?: Event) {
if (event) {
event.stopPropagation();
}
this.selectedLiturgico.set(null);
this.selectedTematico.set(null);
this.showOnlyMine.set(false);
this.showTopTen.set(false);
this.showSuggeriti.set(false);
this.showValidati.set(false);
this.showNonValidati.set(false);
this.limit.set(30);
}
clearPlaylistCard(event?: Event) {
if (event) {
event.stopPropagation();
}
this.playlistService.activeListIds.set([]);
this.playlistService.activeListName.set(null);
this.playlistService.activePlaylistId.set(null);
if (this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive()) {
this.toggleComunitaFilter();
}
this.limit.set(30);
}
onSearchTitleInput(event: any) {
const val = event.target?.value ?? event.detail?.value ?? '';
this.searchTitle.set(val);
this.limit.set(30);
}
onSearchAuthorInput(event: any) {
const val = event.target?.value ?? event.detail?.value ?? '';
this.searchAuthor.set(val);
this.limit.set(30);
}
onSearchTextInput(event: any) {
const val = event.target?.value ?? event.detail?.value ?? '';
this.searchText.set(val);
this.limit.set(30);
}
onSearchNumberInput(event: any) {
const val = event.target?.value ?? event.detail?.value ?? '';
this.searchNumber.set(val);
this.limit.set(30);
}
async deleteMyCanto(id: string, event: Event) { async deleteMyCanto(id: string, event: Event) {
event.stopPropagation(); event.stopPropagation();
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
@@ -1195,7 +1477,8 @@ export class HomePage implements OnDestroy {
this.showValidati() || this.showValidati() ||
this.showNonValidati() || this.showNonValidati() ||
this.playlistService.activeListName() !== null || this.playlistService.activeListName() !== null ||
this.searchQuery() !== ''; this.searchQuery() !== '' ||
this.hasAdvancedSearchParams();
} }
shouldShowSubSectionToolbar(): boolean { shouldShowSubSectionToolbar(): boolean {
@@ -1221,6 +1504,7 @@ export class HomePage implements OnDestroy {
this.playlistService.activeListName.set(null); this.playlistService.activeListName.set(null);
this.playlistService.activePlaylistId.set(null); this.playlistService.activePlaylistId.set(null);
this.searchQuery.set(''); this.searchQuery.set('');
this.clearAdvancedSearch();
this.activeFilterType.set(null); this.activeFilterType.set(null);
this.limit.set(10); this.limit.set(10);
} }
@@ -1251,6 +1535,11 @@ export class HomePage implements OnDestroy {
this.playlistService.activeListIds.set(pl.ids); this.playlistService.activeListIds.set(pl.ids);
this.playlistService.activeListName.set(pl.name); this.playlistService.activeListName.set(pl.name);
this.playlistService.activePlaylistId.set(pl.id); this.playlistService.activePlaylistId.set(pl.id);
if (pl.isRemote || pl.id?.startsWith('remote_')) {
this.playlistService.checkForRemotePlaylistUpdates();
}
this.isAdvancedSearchOpen.set(false);
this.isFilterCardOpen.set(false);
// Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva // Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva
this.limit.set(50); this.limit.set(50);
} }
@@ -1656,10 +1945,16 @@ export class HomePage implements OnDestroy {
} }
} }
toggleVoiceSearch() { toggleVoiceSearch(target: 'global' | 'title' | 'author' | 'text' | 'number' = 'global') {
if (this.audioEngine.isSearching()) { if (this.audioEngine.isSearching()) {
const currentTarget = this.activeVoiceField();
this.audioEngine.stopSearchRecognition(); this.audioEngine.stopSearchRecognition();
if (currentTarget !== target) {
this.activeVoiceField.set(target);
this.audioEngine.startSearchRecognition();
}
} else { } else {
this.activeVoiceField.set(target);
this.audioEngine.startSearchRecognition(); this.audioEngine.startSearchRecognition();
} }
} }
@@ -1730,18 +2025,57 @@ export class HomePage implements OnDestroy {
isComunitaPlaylist(): boolean { isComunitaPlaylist(): boolean {
const id = this.playlistService.activePlaylistId(); const id = this.playlistService.activePlaylistId();
return !!id && id.startsWith('comunita_'); if (id && id.startsWith('comunita_')) return true;
const name = this.playlistService.activeListName();
if (name) {
const pl = this.playlistService.allPlaylists().find(p => p.name === name);
return !!pl?.isComunita;
}
return false;
} }
isRemotePlaylist(): boolean { isRemotePlaylist(): boolean {
const id = this.playlistService.activePlaylistId(); const id = this.playlistService.activePlaylistId();
return !!id && id.startsWith('remote_'); if (id && id.startsWith('remote_')) return true;
const name = this.playlistService.activeListName();
if (name) {
const pl = this.playlistService.allPlaylists().find(p => p.name === name);
return !!pl?.isRemote;
}
return false;
}
async refreshActiveRemotePlaylist() {
const loading = await this.loadingCtrl.create({
message: 'Aggiornamento playlist...'
});
await loading.present();
try {
await this.playlistService.refreshRemotePlaylist();
const toast = await this.toastCtrl.create({
message: 'Playlist aggiornata da remoto!',
duration: 2000,
color: 'success'
});
await toast.present();
} catch (e) {
const alert = await this.alertCtrl.create({
header: 'Errore',
message: 'Impossibile aggiornare la playlist da remoto.',
buttons: ['OK']
});
await alert.present();
} finally {
await loading.dismiss();
}
} }
isActivePlaylistSaved(): boolean { isActivePlaylistSaved(): boolean {
const id = this.playlistService.activePlaylistId(); const id = this.playlistService.activePlaylistId();
if (!id) return false; if (id) return this.playlistService.playlists().some(p => p.id === id);
return this.playlistService.playlists().some(p => p.id === id); const name = this.playlistService.activeListName();
if (name) return this.playlistService.playlists().some(p => p.name === name);
return false;
} }
clearSpecialList(event?: Event) { clearSpecialList(event?: Event) {
@@ -1771,6 +2105,59 @@ export class HomePage implements OnDestroy {
this.playlistService.sharePlaylistQR(ids, name, songSettings); this.playlistService.sharePlaylistQR(ids, name, songSettings);
} }
async printActivePlaylist() {
const ids = this.playlistService.activeListIds();
if (ids.length === 0) return;
const allCanti = this.cantiService.canti();
const myCantiList = this.myCantiService.myCanti();
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
const communityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive();
const songs = ids.map(id => {
let found: any = null;
if (communityActive) {
found = comunitaCantiPers.find(c => c.id === id);
}
if (!found) {
found = allCanti.find(c => c.id === id);
}
if (!found) {
found = myCantiList.find(c => c.id === id);
}
if (!found) {
found = comunitaCantiPers.find(c => c.id === id);
}
return found;
}).filter(c => !!c);
const alert = await this.alertCtrl.create({
header: 'Esporta in PDF',
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
buttons: [
{
text: 'Solo Testo',
handler: () => {
const name = this.playlistService.activeListName() || 'Playlist';
this.playlistService.printPlaylist(name, songs, false);
}
},
{
text: 'Testo e Accordi',
handler: () => {
const name = this.playlistService.activeListName() || 'Playlist';
this.playlistService.printPlaylist(name, songs, true);
}
},
{
text: 'Annulla',
role: 'cancel'
}
]
});
await alert.present();
}
async cloneActivePlaylist() { async cloneActivePlaylist() {
const id = this.playlistService.activePlaylistId(); const id = this.playlistService.activePlaylistId();
const currentName = this.playlistService.activeListName() || 'Playlist'; const currentName = this.playlistService.activeListName() || 'Playlist';
@@ -1807,7 +2194,9 @@ export class HomePage implements OnDestroy {
color: 'success' color: 'success'
}); });
await toast.present(); await toast.present();
return true;
} }
return false;
} }
} }
] ]
@@ -1925,6 +2314,44 @@ export class HomePage implements OnDestroy {
return index !== -1 ? index + 1 : 0; return index !== -1 ? index + 1 : 0;
} }
getSongTonality(canto: any): string | null {
if (!canto) return null;
const baseKey = this.lyricsParser.deduceTonality(canto.accordi || canto.testo || '');
if (!baseKey) return null;
let semitones = 0;
const activePlaylistId = this.playlistService.activePlaylistId();
if (activePlaylistId) {
let playlistSongSetting: any = null;
if (activePlaylistId.startsWith('remote_')) {
const pl = this.playlistService.remotePlaylist();
if (pl && pl.songSettings && pl.songSettings[canto.id]) {
playlistSongSetting = pl.songSettings[canto.id];
}
} else {
const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId);
if (pl && pl.songSettings && pl.songSettings[canto.id]) {
playlistSongSetting = pl.songSettings[canto.id];
}
}
if (playlistSongSetting && playlistSongSetting.tonalita !== undefined) {
semitones = playlistSongSetting.tonalita;
}
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
const settings = this.comunitaService.comunitaCantiSettings();
const songSetting = settings.find(s => s.id_canti === canto.id_canti || s.id_canti === Number(canto.id));
if (songSetting && songSetting.tonalita !== undefined) {
semitones = songSetting.tonalita;
}
}
if (semitones !== 0) {
return this.lyricsParser.transposeChord(baseKey, semitones);
}
return baseKey;
}
toggleComunitaFilter() { toggleComunitaFilter() {
if (this.comunitaService.comunitaCode()) { if (this.comunitaService.comunitaCode()) {
this.comunitaService.isFilterActive.update(v => !v); this.comunitaService.isFilterActive.update(v => !v);
@@ -1966,6 +2393,36 @@ export class HomePage implements OnDestroy {
} }
} }
private panelStartX: number = 0;
private panelStartY: number = 0;
onPanelTouchStart(event: TouchEvent) {
if (event.touches.length === 1) {
this.panelStartX = event.touches[0].clientX;
this.panelStartY = event.touches[0].clientY;
}
}
onPanelTouchEnd(event: TouchEvent, panelType: 'advancedSearch' | 'playlistCard' | 'filterCard') {
if (event.changedTouches.length === 1) {
const endX = event.changedTouches[0].clientX;
const endY = event.changedTouches[0].clientY;
const diffX = endX - this.panelStartX;
const diffY = endY - this.panelStartY;
// Swipe UP: vertical movement upwards (diffY negative, e.g. < -40) and larger than horizontal diff
if (diffY < -40 && Math.abs(diffY) > Math.abs(diffX)) {
if (panelType === 'advancedSearch') {
this.isAdvancedSearchOpen.set(false);
} else if (panelType === 'playlistCard') {
this.isPlaylistCardOpen.set(false);
} else if (panelType === 'filterCard') {
this.isFilterCardOpen.set(false);
}
}
}
}
async navigateMassDate(direction: number) { async navigateMassDate(direction: number) {
const list = this.cantiLettureService.availableMasses(); const list = this.cantiLettureService.availableMasses();
if (list.length === 0) return; if (list.length === 0) return;
+89 -31
View File
@@ -1,37 +1,70 @@
<ion-header [translucent]="true" class="ion-no-border"> <ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar class="bg-gradient top-toolbar"> <ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
<ion-buttons slot="start"> <div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons> <!-- Row 1: Number + Title + Settings -->
<ion-title class="outfit-font wrapped-title"> <div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1"> <span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
<span class="canto-number" *ngIf="canto()?.id_canti"> [style.fontSize.rem]="0.85 * fontSize()"
[style.width.px]="32 * fontSize()"
[style.height.px]="32 * fontSize()"
[style.lineHeight.px]="28 * fontSize()"
[style.borderRadius.px]="8 * fontSize()">
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }} {{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span> </span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <span class="canto-title"
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span> [style.fontSize.rem]="1.15 * fontSize()"
style="font-weight: 700; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span> <span>{{ canto()?.titolo || 'Player' }}</span>
</span> </span>
<ion-button routerLink="/settings" class="settings-btn" fill="clear" style="flex-shrink: 0; margin: 0; --color: var(--ion-color-secondary);">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button>
</div> </div>
</ion-title>
<ion-buttons slot="end"> <!-- Row 2: Author -->
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()"> <div *ngIf="!isLandscapeActive()"
<ion-icon name="cloud-offline-outline"></ion-icon> style="display: block; width: 100%; margin-top: 1px; margin-bottom: 2px;"
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
<span class="song-author"
[style.fontSize.rem]="0.95 * fontSize()"
style="color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
{{ canto()?.autore || 'Autore sconosciuto' }}
</span>
</div> </div>
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon> <!-- Row 3: Badges only -->
</ion-button> <div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> style="margin-top: 2px; width: 100%;"
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon> [style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
</ion-button> <div style="display: flex; align-items: center; gap: inherit;">
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> <ng-container *ngIf="settingsService.showDurationBpmTonality()">
<ion-icon slot="icon-only" <span *ngIf="canto()?.durata" class="song-duration-badge"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'" [style.fontSize.rem]="0.75 * fontSize()"
[color]="showChords() ? 'secondary' : 'medium'" [style.height.px]="26 * fontSize()"
style="font-size: 1.3rem;"> style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
</ion-icon> <ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
</ion-button> {{ canto()?.durata }}
</ion-buttons> </span>
<span *ngIf="canto()?.bpm" class="song-bpm-badge"
[style.fontSize.rem]="0.75 * fontSize()"
[style.height.px]="26 * fontSize()"
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="pulse-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
{{ canto()?.bpm }} BPM
</span>
<span *ngIf="tonality()" class="song-tonality-badge"
[style.fontSize.rem]="0.75 * fontSize()"
[style.height.px]="26 * fontSize()"
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="musical-note-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
{{ tonality() }}
</span>
</ng-container>
</div>
</div>
</div>
</ion-toolbar> </ion-toolbar>
<!-- Audio Toolbar Removed --> <!-- Audio Toolbar Removed -->
@@ -105,7 +138,7 @@
</div> </div>
<!-- Sections rendering --> <!-- Sections rendering -->
<div class="lyrics-view"> <div class="lyrics-view" (click)="handleLyricsClick($event)">
<div *ngFor="let section of parsedSections(); let si = index" <div *ngFor="let section of parsedSections(); let si = index"
class="section" class="section"
[class.chorus]="section.type === 'chorus'" [class.chorus]="section.type === 'chorus'"
@@ -145,6 +178,9 @@
<ion-toolbar class="global-player-toolbar glass"> <ion-toolbar class="global-player-toolbar glass">
<div class="player-content"> <div class="player-content">
<div class="controls-row"> <div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="goHome()" class="skip-btn">
<ion-icon slot="icon-only" name="home-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn"> <ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
<ion-icon slot="icon-only" name="chevron-back"></ion-icon> <ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button> </ion-button>
@@ -209,7 +245,7 @@
</div> </div>
</div> </div>
<!-- Navigation --> <!-- Navigation (Avanzamento Karaoke e Riparti da inizio) -->
<div class="group"> <div class="group">
<ion-button fill="clear" size="small" (click)="restart()"> <ion-button fill="clear" size="small" (click)="restart()">
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon> <ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
@@ -222,6 +258,28 @@
</ion-button> </ion-button>
</div> </div>
<!-- Action Buttons (edit, condividi, accordi) -->
<div class="group">
<ion-button fill="clear" size="small" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()">
<ion-icon slot="icon-only" name="create-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="deleteMyCanto()" *ngIf="settingsService.showEditor() && canto()?.id?.startsWith('my_')" color="danger">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="shareCanto()">
<ion-icon slot="icon-only" name="share-social-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="toggleChords()">
<ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
[color]="showChords() ? 'secondary' : 'medium'">
</ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="printCanto()" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Transposition (Only in chords mode) --> <!-- Transposition (Only in chords mode) -->
<div class="group" *ngIf="showChords()"> <div class="group" *ngIf="showChords()">
<ion-button fill="clear" size="small" (click)="transposeDown()"> <ion-button fill="clear" size="small" (click)="transposeDown()">
@@ -229,7 +287,8 @@
</ion-button> </ion-button>
<div class="transpose-indicator"> <div class="transpose-indicator">
<ion-icon name="musical-note" color="secondary"></ion-icon> <ion-icon name="musical-note" color="secondary"></ion-icon>
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span> <span class="val" *ngIf="tonality()">{{ tonality() }}</span>
<span class="val" *ngIf="transposeAmount() !== 0" style="opacity: 0.8; font-size: 0.75rem; margin-left: 2px;">({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})</span>
</div> </div>
<ion-button fill="clear" size="small" (click)="transposeUp()"> <ion-button fill="clear" size="small" (click)="transposeUp()">
<ion-icon slot="icon-only" name="add"></ion-icon> <ion-icon slot="icon-only" name="add"></ion-icon>
@@ -266,7 +325,6 @@
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()"> <div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()">
<div class="black-screen-content" (click)="$event.stopPropagation()"> <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%;"> <div (click)="deactivateBlackScreen()" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
<ion-icon name="car-outline" class="car-mode-icon"></ion-icon>
<p class="car-mode-text outfit-font">Schermo nero attivo</p> <p class="car-mode-text outfit-font">Schermo nero attivo</p>
</div> </div>
+170 -42
View File
@@ -3,6 +3,13 @@
background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
} }
.canto-title {
transition: font-size 0.15s ease;
&.small-title {
font-size: 1.12rem !important;
}
}
.outfit-font { .outfit-font {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
} }
@@ -20,40 +27,71 @@
text-align: left !important; text-align: left !important;
} }
.canto-number {
position: relative;
width: 32px;
height: 32px;
line-height: 28px;
text-align: center;
background: rgba(var(--ion-color-secondary-rgb), 0.1);
color: var(--ion-color-secondary);
font-size: 0.85rem;
border-radius: 8px;
font-weight: 700;
border: 2px solid var(--ion-color-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.clickable {
cursor: pointer;
transition: all 0.2s ease;
&:active { transform: scale(0.9); }
}
&.selected {
background: var(--ion-color-secondary);
color: #000;
border-color: var(--ion-color-secondary);
box-shadow: 0 0 10px rgba(var(--ion-color-secondary-rgb), 0.4);
}
}
.settings-btn {
--color: var(--ion-color-secondary);
--padding-start: 0;
--padding-end: 0;
margin-left: 0;
margin-right: 0;
width: 38px;
min-width: 38px;
height: 38px;
ion-icon {
font-size: 1.6rem;
}
}
.title-main { .title-main {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 12px; gap: 12px;
font-size: 1.15rem; // Fisso: non influenzato dallo zoom del testo
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
white-space: normal; white-space: normal;
line-height: 1.2; line-height: 1.2;
text-align: left; text-align: left;
.canto-number { .song-author {
font-size: 0.85em; font-size: 0.75rem;
background: rgba(var(--ion-color-secondary-rgb), 0.15); color: rgba(255, 255, 255, 0.55);
padding: 2px 10px; font-weight: 400;
display: inline-flex; display: inline-block;
align-items: center; font-family: 'Outfit', sans-serif;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
flex-shrink: 0;
&.clickable {
cursor: pointer;
transition: all 0.2s ease;
&:active { transform: scale(0.9); }
}
&.selected {
background: var(--ion-color-secondary);
color: #000;
border-color: var(--ion-color-secondary);
box-shadow: 0 0 10px rgba(var(--ion-color-secondary-rgb), 0.4);
}
} }
.title-text { .title-text {
@@ -61,6 +99,102 @@
text-align: left; text-align: left;
} }
} }
.scroll-horizontal {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 12px;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
height: 26px;
flex-shrink: 0;
ion-icon {
font-size: 1.05rem;
}
}
.header-action-btn {
margin: 0;
--padding-start: 4px;
--padding-end: 4px;
height: 38px;
width: 38px;
flex-shrink: 0;
ion-icon {
font-size: 1.65rem;
}
}
// Responsive scaling based on viewport width
@media (max-width: 480px) {
gap: 4px;
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
font-size: 0.68rem;
padding: 1px 3px;
height: 22px;
gap: 2px;
ion-icon {
font-size: 0.9rem;
}
}
.header-action-btn {
height: 32px;
width: 32px;
--padding-start: 0px;
--padding-end: 0px;
ion-icon {
font-size: 1.35rem;
}
}
}
@media (max-width: 360px) {
gap: 2px;
.song-duration-badge, .song-bpm-badge, .song-tonality-badge {
font-size: 0.62rem;
padding: 1px 2px;
height: 20px;
gap: 1px;
ion-icon {
font-size: 0.8rem;
}
}
.header-action-btn {
height: 28px;
width: 28px;
--padding-start: 0px;
--padding-end: 0px;
ion-icon {
font-size: 1.15rem;
}
}
}
}
} }
// Content and Lyrics // Content and Lyrics
@@ -105,25 +239,14 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
.play-btn { .play-btn, .skip-btn, .close-btn {
--padding-start: 0; --padding-start: 0;
--padding-end: 0; --padding-end: 0;
height: 44px; height: 40px;
width: 44px; width: 40px;
ion-icon { ion-icon {
font-size: 2.2rem; font-size: 1.6rem;
}
}
.skip-btn, .close-btn {
--padding-start: 0;
--padding-end: 0;
height: 36px;
width: 36px;
ion-icon {
font-size: 1.4rem;
} }
} }
@@ -144,7 +267,7 @@
} }
.section { .section {
margin-bottom: 2rem; margin-bottom: 1.2rem;
position: relative; position: relative;
&.chorus { &.chorus {
@@ -160,21 +283,22 @@
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
opacity: 0.7; opacity: 0.7;
letter-spacing: 1px; letter-spacing: 1px;
} }
} }
.lyric-line { .lyric-line {
margin-bottom: 1rem; margin-bottom: 0.4rem;
line-height: 1.6; line-height: 1.35;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 1.5em; min-height: 1.5em;
white-space: normal; white-space: normal;
overflow-wrap: break-word; overflow-wrap: break-word;
word-break: break-word; word-break: break-word;
letter-spacing: 3px;
&.active { &.active {
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
@@ -731,6 +855,10 @@ ion-content.full-screen-content {
} }
:host-context(body.high-contrast) { :host-context(body.high-contrast) {
.title-main .song-author {
color: rgba(0, 0, 0, 0.6) !important;
}
.slim-toolbar { .slim-toolbar {
--background: #ffffff !important; --background: #ffffff !important;
background: #ffffff !important; background: #ffffff !important;
+171 -32
View File
@@ -26,6 +26,7 @@ import { FaceDetectorService } from '../../services/face-detector.service';
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public canto = signal<Canto | null>(null); public canto = signal<Canto | null>(null);
public activeSongId = signal<string | null>(null); public activeSongId = signal<string | null>(null);
public isTitleWrapped = signal<boolean>(false);
@HostListener('window:keydown', ['$event']) @HostListener('window:keydown', ['$event'])
handleKeyDown(event: KeyboardEvent) { handleKeyDown(event: KeyboardEvent) {
@@ -42,6 +43,30 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} else if (event.key === 'MediaPlayPause') { } else if (event.key === 'MediaPlayPause') {
this.toggleAudio(); this.toggleAudio();
event.preventDefault(); 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);
} }
} }
@@ -108,6 +133,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public transposeAmount = signal<number>(0); public transposeAmount = signal<number>(0);
public tonality = computed(() => {
const song = this.canto();
if (!song) return null;
const baseKey = this.lyricsParser.deduceTonality(song.accordi || song.testo || '');
if (!baseKey) return null;
const amount = this.transposeAmount();
if (amount === 0) return baseKey;
return this.lyricsParser.transposeChord(baseKey, amount);
});
public currentLineIndex = signal<number>(0); public currentLineIndex = signal<number>(0);
public math = Math; public math = Math;
public youtubePlayerService = inject(YoutubePlayerService); public youtubePlayerService = inject(YoutubePlayerService);
@@ -155,7 +191,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public faceDetector = inject(FaceDetectorService); public faceDetector = inject(FaceDetectorService);
private meta = inject(Meta); private meta = inject(Meta);
public enableCameraNavigation = signal<boolean>(false); public enableCameraNavigation = computed(() => this.settingsService.cameraNavigationActive());
private songStartTime: number = 0; private songStartTime: number = 0;
@@ -192,6 +228,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (this.playlistService.autoPlayPlaylist()) { if (this.playlistService.autoPlayPlaylist()) {
setTimeout(() => this.initPlayer(c.id), 500); setTimeout(() => this.initPlayer(c.id), 500);
} }
this.isTitleWrapped.set(false);
setTimeout(() => this.checkTitleWrap(), 150);
} }
}); });
@@ -289,13 +327,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (playlistSongSetting) { if (playlistSongSetting) {
this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0); this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0);
this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2); this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2);
if (playlistSongSetting.zoom !== undefined) { const gZoom = this.settingsService.globalZoom();
this.fontSize.set(playlistSongSetting.zoom); this.fontSize.set(gZoom);
this.portraitFontSize = playlistSongSetting.zoom; this.portraitFontSize = gZoom;
} else {
this.fontSize.set(1.0);
this.portraitFontSize = 1.0;
}
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) { } else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
const settings = this.comunitaService.comunitaCantiSettings(); const settings = this.comunitaService.comunitaCantiSettings();
const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id)); const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id));
@@ -315,13 +349,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.transposeAmount.set(0); this.transposeAmount.set(0);
this.autoscrollSpeed.set(2); this.autoscrollSpeed.set(2);
} }
this.fontSize.set(1.0); const gZoom = this.settingsService.globalZoom();
this.portraitFontSize = 1.0; this.fontSize.set(gZoom);
this.portraitFontSize = gZoom;
} else { } else {
this.transposeAmount.set(0); this.transposeAmount.set(0);
this.autoscrollSpeed.set(2); this.autoscrollSpeed.set(2);
this.fontSize.set(1.0); const gZoom = this.settingsService.globalZoom();
this.portraitFontSize = 1.0; this.fontSize.set(gZoom);
this.portraitFontSize = gZoom;
} }
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
@@ -344,6 +380,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
onResize(event: any) { onResize(event: any) {
this.windowLandscape.set(window.innerWidth > window.innerHeight); this.windowLandscape.set(window.innerWidth > window.innerHeight);
this.checkLandscapeZoom(); this.checkLandscapeZoom();
this.checkTitleWrap();
} }
private isLandscape(): boolean { private isLandscape(): boolean {
@@ -466,8 +503,26 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
checkTitleWrap() {
const el = document.querySelector('.canto-title') as HTMLElement;
if (!el) return;
this.isTitleWrapped.set(false);
setTimeout(() => {
if (el.scrollHeight > 30) {
this.isTitleWrapped.set(true);
}
}, 50);
}
ngAfterViewInit() { ngAfterViewInit() {
this.checkLandscapeZoom(); this.checkLandscapeZoom();
this.checkTitleWrap();
if (this.settingsService.cameraNavigationActive()) {
this.initCameraTracking();
}
const gestureX = this.gestureCtrl.create({ const gestureX = this.gestureCtrl.create({
el: this.el.nativeElement, el: this.el.nativeElement,
direction: 'x', direction: 'x',
@@ -553,6 +608,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.fontSize.set(limited); this.fontSize.set(limited);
if (!this.isLandscape()) { if (!this.isLandscape()) {
this.portraitFontSize = limited; this.portraitFontSize = limited;
this.settingsService.globalZoom.set(limited);
} }
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
} }
@@ -591,8 +647,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
activePlaylistId, activePlaylistId,
c.id, c.id,
this.transposeAmount(), this.transposeAmount(),
this.autoscrollSpeed(), this.autoscrollSpeed()
this.fontSize()
); );
} }
} }
@@ -620,6 +675,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.fontSize.set(limited); this.fontSize.set(limited);
if (!this.isLandscape()) { if (!this.isLandscape()) {
this.portraitFontSize = limited; this.portraitFontSize = limited;
this.settingsService.globalZoom.set(limited);
} }
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings(); this.updatePlaylistSettings();
@@ -634,6 +690,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.fontSize.set(target); this.fontSize.set(target);
if (!this.isLandscape()) { if (!this.isLandscape()) {
this.portraitFontSize = target; this.portraitFontSize = target;
this.settingsService.globalZoom.set(target);
} }
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings(); this.updatePlaylistSettings();
@@ -662,6 +719,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.youtubePlayerService.seekTo(event.detail.value); this.youtubePlayerService.seekTo(event.detail.value);
} }
goHome() {
this.router.navigate(['/home']);
}
restart() { restart() {
this.currentLineIndex.set(0); this.currentLineIndex.set(0);
this.youtubePlayerService.seekTo(0); this.youtubePlayerService.seekTo(0);
@@ -919,12 +980,32 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
nextSong() { private getSequenceList(): string[] {
let list = this.playlistService.activeListIds(); const currentId = this.canto()?.id;
if (list.length === 0) {
list = this.cantiService.canti().map(c => c.id); // 1. Homepage filtered list (includes current sorting like Top Ten, Suggeriti, and any active playlist + additional filters)
const filtered = this.playlistService.filteredListIds();
if (filtered.length > 0) {
if (!currentId || filtered.includes(currentId)) {
return filtered;
}
} }
// 2. Active playlist raw list if present and contains current song
const active = this.playlistService.activeListIds();
if (active.length > 0) {
if (!currentId || active.includes(currentId)) {
return active;
}
}
// 3. Default fallback: all canti
return this.cantiService.canti().map(c => c.id);
}
nextSong() {
const list = this.getSequenceList();
const currentId = this.canto()?.id; const currentId = this.canto()?.id;
if (!currentId) return; if (!currentId) return;
@@ -934,17 +1015,14 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (this.isLandscapeActive()) { if (this.isLandscapeActive()) {
this.isBlackScreen.set(true); this.isBlackScreen.set(true);
} }
this.router.navigate(['/player'], { queryParams: { id: nextId } }); this.router.navigate(['/player'], { queryParams: { id: nextId }, replaceUrl: true });
} else { } else {
this.playlistService.autoPlayPlaylist.set(false); this.playlistService.autoPlayPlaylist.set(false);
} }
} }
prevSong() { prevSong() {
let list = this.playlistService.activeListIds(); const list = this.getSequenceList();
if (list.length === 0) {
list = this.cantiService.canti().map(c => c.id);
}
const currentId = this.canto()?.id; const currentId = this.canto()?.id;
if (!currentId) return; if (!currentId) return;
@@ -955,7 +1033,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (this.isLandscapeActive()) { if (this.isLandscapeActive()) {
this.isBlackScreen.set(true); this.isBlackScreen.set(true);
} }
this.router.navigate(['/player'], { queryParams: { id: prevId } }); this.router.navigate(['/player'], { queryParams: { id: prevId }, replaceUrl: true });
} }
} }
@@ -963,7 +1041,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { id }, queryParams: { id },
queryParamsHandling: 'merge' queryParamsHandling: 'merge',
replaceUrl: true
}); });
this.restart(); this.restart();
} }
@@ -1054,6 +1133,27 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
async deleteMyCanto() {
const c = this.canto();
if (!c || !c.id.startsWith('my_')) return;
const alert = await this.alertCtrl.create({
header: 'Elimina Canto',
message: 'Sei sicuro di voler eliminare questo canto dai tuoi brani personali?',
buttons: [
{ text: 'Annulla', role: 'cancel' },
{
text: 'Elimina',
role: 'destructive',
handler: () => {
this.myCantiService.deleteCanto(c.id);
this.router.navigate(['/home']);
}
}
]
});
await alert.present();
}
async shareCanto() { async shareCanto() {
const c = this.canto(); const c = this.canto();
if (!c) return; if (!c) return;
@@ -1135,6 +1235,35 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
async printCanto() {
const c = this.canto();
if (!c) return;
const alert = await this.alertCtrl.create({
header: 'Esporta in PDF',
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
buttons: [
{
text: 'Solo Testo',
handler: () => {
this.playlistService.printPlaylist(c.titolo, [c], false, { [c.id || c.id_canti]: this.transposeAmount() });
}
},
{
text: 'Testo e Accordi',
handler: () => {
this.playlistService.printPlaylist(c.titolo, [c], true, { [c.id || c.id_canti]: this.transposeAmount() });
}
},
{
text: 'Annulla',
role: 'cancel'
}
]
});
await alert.present();
}
private initPlayer(id: string) { private initPlayer(id: string) {
if (!this.youtubePlayerService.isPlayerSupported()) { if (!this.youtubePlayerService.isPlayerSupported()) {
return; return;
@@ -1241,7 +1370,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
toggleCameraNavigation() { toggleCameraNavigation() {
if (this.enableCameraNavigation()) { if (this.settingsService.cameraNavigationActive()) {
this.stopCameraNavigation(); this.stopCameraNavigation();
} else { } else {
this.startCameraNavigation(); this.startCameraNavigation();
@@ -1249,7 +1378,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
startCameraNavigation() { startCameraNavigation() {
this.enableCameraNavigation.set(true); this.settingsService.cameraNavigationActive.set(true);
this.initCameraTracking();
}
initCameraTracking() {
if (!this.settingsService.cameraNavigationActive()) return;
setTimeout(async () => { setTimeout(async () => {
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement; const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
@@ -1263,16 +1397,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.prev(true); this.prev(true);
} }
}); });
} catch (e) { } catch (e: any) {
this.enableCameraNavigation.set(false); this.settingsService.cameraNavigationActive.set(false);
alert('Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.'); const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto';
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
} }
} }
}, 300); }, 300);
} }
stopCameraNavigation() { stopCameraNavigation() {
this.enableCameraNavigation.set(false); this.settingsService.cameraNavigationActive.set(false);
this.faceDetector.stop(); this.faceDetector.stop();
} }
@@ -1280,7 +1415,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.exitFullscreen(); this.exitFullscreen();
this.stopAutoscroll(); this.stopAutoscroll();
this.logPreviousSongTime(); this.logPreviousSongTime();
this.stopCameraNavigation(); if (this.settingsService.cameraNavigationActive()) {
this.faceDetector.pause();
} else {
this.faceDetector.stop();
}
this.channel.close(); this.channel.close();
} }
+9 -2
View File
@@ -5,6 +5,9 @@
</ion-buttons> </ion-buttons>
<ion-title class="outfit-font">Gestione Playlist</ion-title> <ion-title class="outfit-font">Gestione Playlist</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button (click)="exportPlaylistPdf()" [disabled]="localSongs.length === 0" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
</ion-button>
<ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0"> <ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
<ion-icon slot="icon-only" name="save-outline"></ion-icon> <ion-icon slot="icon-only" name="save-outline"></ion-icon>
</ion-button> </ion-button>
@@ -14,8 +17,12 @@
<ion-content class="bg-gradient"> <ion-content class="bg-gradient">
<div class="ion-padding"> <div class="ion-padding">
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0"> <div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
<p>Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p> <p style="margin: 0;">Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
<div *ngIf="totalDuration" style="background: rgba(var(--ion-color-secondary-rgb), 0.25); border: 1px solid var(--ion-color-secondary); padding: 4px 10px; border-radius: 10px; font-weight: 800; color: var(--ion-color-secondary); display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-family: 'Outfit', sans-serif; white-space: nowrap; margin-left: 12px;">
<ion-icon name="time-outline" style="font-size: 1rem;"></ion-icon>
{{ totalDuration }}
</div>
</div> </div>
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)"> <div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
+74
View File
@@ -51,6 +51,42 @@ export class PlaylistPage {
public qrCodeImage: string | null = null; public qrCodeImage: string | null = null;
public savedPlaylistName: string | null = null; public savedPlaylistName: string | null = null;
get totalDuration(): string {
const songs = this.localSongs;
if (songs.length === 0) return '';
let totalSeconds = 0;
for (const song of songs) {
if (song && song.durata) {
totalSeconds += this.parseDuration(song.durata);
}
}
if (totalSeconds === 0) return '';
return this.formatTotalDuration(totalSeconds);
}
private parseDuration(dur: string): number {
if (!dur) return 0;
const parts = dur.split(':').map(Number);
if (parts.some(isNaN)) return 0;
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
} else if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return 0;
}
private formatTotalDuration(seconds: number): string {
const hh = Math.floor(seconds / 3600);
const mm = Math.floor((seconds % 3600) / 60);
const ss = seconds % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
if (hh > 0) {
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
return `${pad(mm)}:${pad(ss)}`;
}
constructor() { constructor() {
// Initial copy to allow local reordering // Initial copy to allow local reordering
this.localSongs = [...this.selectedSongs()]; this.localSongs = [...this.selectedSongs()];
@@ -236,4 +272,42 @@ export class PlaylistPage {
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id); const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
return index !== -1 ? index + 1 : 0; return index !== -1 ? index + 1 : 0;
} }
async exportPlaylistPdf() {
if (this.localSongs.length === 0) return;
const songSettings = this.getPlaylistSongSettings() || {};
const transpositions: { [songId: string]: number } = {};
for (const key of Object.keys(songSettings)) {
if (songSettings[key] && songSettings[key].tonalita !== undefined) {
transpositions[key] = songSettings[key].tonalita;
}
}
const alert = await this.alertCtrl.create({
header: 'Esporta in PDF',
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
buttons: [
{
text: 'Solo Testo',
handler: () => {
const name = this.savedPlaylistName || 'Playlist';
this.playlistService.printPlaylist(name, this.localSongs, false, transpositions);
}
},
{
text: 'Testo e Accordi',
handler: () => {
const name = this.savedPlaylistName || 'Playlist';
this.playlistService.printPlaylist(name, this.localSongs, true, transpositions);
}
},
{
text: 'Annulla',
role: 'cancel'
}
]
});
await alert.present();
}
} }
@@ -50,6 +50,18 @@
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input> <ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
</ion-item> </ion-item>
<div class="category-selectors">
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Durata (mm:ss)</ion-label>
<ion-input [(ngModel)]="durata" placeholder="Es: 03:45"></ion-input>
</ion-item>
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">BPM (Tempo)</ion-label>
<ion-input type="number" [(ngModel)]="bpm" placeholder="Es: 120"></ion-input>
</ion-item>
</div>
<div class="category-selectors"> <div class="category-selectors">
<ion-item class="custom-input-item select-item"> <ion-item class="custom-input-item select-item">
<ion-label position="stacked">Momento Liturgico</ion-label> <ion-label position="stacked">Momento Liturgico</ion-label>
@@ -73,9 +85,14 @@
<!-- TOOLBARS --> <!-- TOOLBARS -->
<div class="toolbar-section"> <div class="toolbar-section">
<div class="horizontal-toolbar"> <div class="horizontal-toolbar">
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)"> <ng-container *ngFor="let tag of commonTags">
{{ tag.label }} <ion-button size="small" fill="outline" (click)="insertText(tag.start)">
</ion-button> {{ tag.label }}
</ion-button>
<ion-button *ngIf="tag.end" size="small" fill="outline" color="medium" (click)="insertText(tag.end)">
Fine {{ tag.label }}
</ion-button>
</ng-container>
</div> </div>
</div> </div>
@@ -109,12 +126,28 @@
<!-- EDITOR AREA --> <!-- EDITOR AREA -->
<div class="editor-wrapper" [class.hc]="isHighContrast"> <div class="editor-wrapper" [class.hc]="isHighContrast">
<div class="editor-header"> <div class="editor-header">
<div class="editor-title-group"> <div class="editor-title-group" style="display: flex; align-items: center; gap: 8px;">
<span>Editor Testo</span> <span>Editor Testo</span>
<!-- Transpose buttons in left column, above textarea -->
<div class="editor-transpose-group" style="display: flex; align-items: center; gap: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px 6px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); margin-left: 12px;">
<ion-button fill="clear" size="small" (click)="transposeDown()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
<ion-icon slot="icon-only" name="remove-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
<span class="val outfit-font" style="font-size: 0.85rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 24px; text-align: center;">T:{{ transposeAmount > 0 ? '+' : '' }}{{ transposeAmount }}</span>
<ion-button fill="clear" size="small" (click)="transposeUp()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
<ion-icon slot="icon-only" name="add-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
</div>
<!-- Set default key button -->
<ion-button *ngIf="transposeAmount !== 0" fill="outline" color="secondary" size="small" (click)="setDefaultTonalita()" style="margin: 0 0 0 8px; font-size: 10px; font-weight: 700; height: 24px; --border-radius: 6px;">
Imposta tonalità di default
</ion-button>
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica"> <ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica">
<ion-icon name="undo-outline"></ion-icon> <ion-icon name="arrow-undo-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" size="small" (click)="deduceChords()" [disabled]="!content" title="Deduci accordi per le altre strofe"> <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-icon name="musical-notes-outline"></ion-icon>
@@ -125,25 +158,58 @@
<ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF"> <ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF">
<ion-icon name="document-attach-outline"></ion-icon> <ion-icon name="document-attach-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" size="small" (click)="toggleRawPasteMode()" [color]="isRawPasteModeActive ? 'warning' : 'medium'" [title]="isRawPasteModeActive ? 'Incolla Raw: Attivo (il testo incollato non verrà filtrato)' : 'Attiva Incolla Raw (incolla senza filtri)'" style="font-size: 11px; font-weight: 800; font-family: 'Outfit', sans-serif;">
RAW
</ion-button>
</div> </div>
</div> </div>
<ion-item class="custom-input-item textarea-item"> <div class="editor-textarea-container">
<ion-textarea <!-- Input textarea -->
#contentTextarea <textarea
#nativeTextarea
[(ngModel)]="content" [(ngModel)]="content"
placeholder="Scrivi o scansiona..." placeholder="Scrivi o scansiona..."
rows="18" (paste)="onPaste($event)"
class="content-textarea" spellcheck="false"
(paste)="onPaste($event)"> autocapitalize="none"
</ion-textarea> autocomplete="off"
</ion-item> autocorrect="off">
</textarea>
</div>
</div> </div>
<ion-item class="custom-input-item"> <ion-item class="custom-input-item">
<ion-label position="stacked">Autore / Link YouTube</ion-label> <ion-label position="stacked">Autore</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input> <ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
</ion-item> </ion-item>
<!-- 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> </ion-list>
<div class="action-buttons"> <div class="action-buttons">
@@ -159,7 +225,8 @@
<div class="preview-card" [class.hc]="isHighContrast"> <div class="preview-card" [class.hc]="isHighContrast">
<div class="preview-header"> <div class="preview-header">
<span class="preview-title">Anteprima Attiva</span> <span class="preview-title">Anteprima Attiva</span>
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn">
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn" style="margin: 0;">
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon> <ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }} {{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
</ion-button> </ion-button>
@@ -170,26 +237,28 @@
<p class="preview-song-author" *ngIf="author">{{ author }}</p> <p class="preview-song-author" *ngIf="author">{{ author }}</p>
</div> </div>
<div class="preview-lyrics-container"> <div class="preview-lyrics-container">
<div *ngFor="let section of parsedSections" <div class="lyrics-view">
class="preview-section" <div *ngFor="let section of parsedSections"
[class.chorus]="section.type === 'chorus'" class="section"
[class.verse-num]="section.type === 'verse_num'"> [class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div> <div *ngIf="section.type === 'chorus'" class="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 === '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 --> <!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly"> <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 *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="preview-chord">{{ seg.chord }}</span> <span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="preview-seg-text">{{ seg.text }}</span> <span class="seg-text">{{ seg.text }}</span>
</span> </span>
</ng-container> </ng-container>
<!-- Text only mode --> <!-- Text only mode -->
<ng-template #textOnly> <ng-template #textOnly>
{{ line.text }} {{ line.text }}
</ng-template> </ng-template>
</div>
</div> </div>
</div> </div>
@@ -203,8 +272,6 @@
</div> </div>
</div> </div>
<!-- Hidden inputs --> <!-- Hidden inputs -->
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;"> <input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;"> <input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
@@ -94,11 +94,14 @@ body.high-contrast :host ::ng-deep {
} }
/* Textarea inside high contrast must have white background and black text */ /* Textarea inside high contrast must have white background and black text */
.content-textarea { .editor-textarea-container {
--color: #000000 !important;
color: #000000 !important;
background: #ffffff !important; background: #ffffff !important;
--background: #ffffff !important; border: 2px solid #000000 !important;
textarea {
color: #000000 !important;
caret-color: #000000 !important;
}
} }
/* Force background of inputs and items to be white with single dark gray border in high contrast */ /* Force background of inputs and items to be white with single dark gray border in high contrast */
@@ -267,30 +270,49 @@ body.high-contrast :host ::ng-deep {
} }
} }
.content-textarea { .editor-textarea-container {
--color: #000000 !important; background: #ffffff;
color: #000000 !important;
background: #ffffff !important; textarea {
--background: #ffffff !important; color: #000000;
font-size: 18px !important; caret-color: #000000;
font-weight: 700 !important; }
caret-color: #000000;
} }
} }
} }
:host ::ng-deep { :host ::ng-deep {
.content-textarea { .editor-textarea-container {
font-family: 'Courier New', Courier, monospace; position: relative;
font-size: 15px; width: 100%;
font-weight: 600; height: 450px;
--color: #ffffff !important; background: #111111;
color: #ffffff !important; border-radius: 0 0 14px 14px;
--padding-start: 16px; overflow: hidden;
--padding-end: 16px; box-sizing: border-box;
--padding-top: 16px;
--padding-bottom: 16px; textarea {
min-height: 400px; position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0 !important;
padding: 16px !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
font-size: 1.05rem !important;
line-height: 1.6 !important;
color: #ffffff !important;
background: transparent !important;
width: 100%;
height: 100%;
box-sizing: border-box !important;
resize: none;
overflow-y: auto !important;
caret-color: #64ffda !important;
border: none !important;
outline: none !important;
}
} }
.category-selectors { .category-selectors {
@@ -569,8 +591,14 @@ body.high-contrast :host ::ng-deep {
border-radius: 2px; border-radius: 2px;
} }
.preview-section { .lyrics-view {
margin-bottom: 1.8rem; max-width: 900px;
margin-left: 0;
text-align: left;
}
.section {
margin-bottom: 2rem;
position: relative; position: relative;
&.chorus { &.chorus {
@@ -581,25 +609,30 @@ body.high-contrast :host ::ng-deep {
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
} }
.preview-section-label { .section-label {
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
margin-bottom: 0.4rem; margin-bottom: 0.5rem;
opacity: 0.7; opacity: 0.7;
letter-spacing: 1px; letter-spacing: 1px;
} }
} }
.preview-lyric-line { .lyric-line {
margin-bottom: 0.8rem; margin-bottom: 1rem;
line-height: 1.6; line-height: 1.6;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
min-height: 1.5em; min-height: 1.5em;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
letter-spacing: 3px;
} }
.preview-chord-segment { .chord-segment {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
vertical-align: bottom; vertical-align: bottom;
@@ -609,16 +642,16 @@ body.high-contrast :host ::ng-deep {
margin-right: 0 !important; margin-right: 0 !important;
} }
.preview-chord { .chord {
font-size: 0.78em; font-size: 0.75em;
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
height: 1.25em; height: 1.2em;
margin-bottom: -0.2em; margin-bottom: -0.2em;
padding-right: 0.25em; padding-right: 0.25em;
} }
.preview-seg-text { .seg-text {
white-space: pre; white-space: pre;
&::after { &::after {
content: '\200b'; content: '\200b';
@@ -654,6 +687,16 @@ body.high-contrast :host ::ng-deep {
--color: #000000; --color: #000000;
font-weight: 700; font-weight: 700;
} }
.preview-transpose-group {
border-color: #000000 !important;
span {
color: #000000 !important;
}
ion-button {
--color: #000000 !important;
}
}
} }
.preview-body { .preview-body {
@@ -672,23 +715,23 @@ body.high-contrast :host ::ng-deep {
} }
.preview-lyrics-container { .preview-lyrics-container {
.preview-section { .section {
&.chorus { &.chorus {
background: #f5f5f5; background: #f5f5f5;
border-left: 3px solid #000000; border-left: 3px solid #000000;
} }
.preview-section-label { .section-label {
color: #000000; color: #000000;
} }
} }
.preview-lyric-line { .lyric-line {
color: #000000; color: #000000;
} }
.preview-chord-segment { .chord-segment {
.preview-chord { .chord {
color: #000000; color: #000000;
text-decoration: underline; text-decoration: underline;
font-weight: 800; font-weight: 800;
@@ -703,6 +746,17 @@ 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 STYLES FOR INTUITIVE MOBILE RESPONSIVENESS */
.custom-mobile-segment { .custom-mobile-segment {
display: none; display: none;
+188 -14
View File
@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChild, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular'; import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular';
@@ -9,6 +9,7 @@ import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service'; import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule, Router } from '@angular/router'; import { ActivatedRoute, RouterModule, Router } from '@angular/router';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service'; import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
import { YoutubePlayerService } from '../../services/youtube-player.service';
@Component({ @Component({
selector: 'app-propose-canto', selector: 'app-propose-canto',
@@ -17,8 +18,9 @@ import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, IonicModule, RouterModule] imports: [CommonModule, FormsModule, IonicModule, RouterModule]
}) })
export class ProposeCantoPage implements OnInit { export class ProposeCantoPage implements OnInit, OnDestroy {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea; @ViewChild('nativeTextarea', { static: false }) nativeTextarea!: ElementRef<HTMLTextAreaElement>;
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef; @ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
@ViewChild('fileInput', { static: false }) fileInput!: ElementRef; @ViewChild('fileInput', { static: false }) fileInput!: ElementRef;
@@ -31,12 +33,155 @@ export class ProposeCantoPage implements OnInit {
private router = inject(Router); private router = inject(Router);
public lyricsParser = inject(LyricsParserService); public lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController); private alertCtrl = inject(AlertController);
public youtubePlayerService = inject(YoutubePlayerService);
showChordsPreview: boolean = true; showChordsPreview: boolean = true;
activeTab: string = 'editor'; activeTab: string = 'editor';
transposeAmount: number = 0;
isRawPasteModeActive: boolean = false;
get highlightedHtml(): string {
if (!this.content) return '';
let escaped = this.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
escaped = escaped.replace(/\[([^\]]+)\]/g, (match, chord) => {
const transposed = this.transposeAmount !== 0
? this.lyricsParser.transposeChord(chord, this.transposeAmount)
: chord;
return `<span class="editor-chord">[${transposed}]</span>`;
});
const lines = escaped.split('\n');
let htmlLines: string[] = [];
let inChorus = false;
let inVerse = false;
let inVerseNum = false;
for (const line of lines) {
const trimmed = line.trim();
let currentInChorus = inChorus;
let currentInVerse = inVerse;
let currentInVerseNum = inVerseNum;
let isTagLine = false;
let processedContent = line;
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
inChorus = true;
currentInChorus = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_chorus}' || trimmed === '{eoc}') {
inChorus = false;
currentInChorus = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{start_verse}' || trimmed === '{sov}') {
inVerse = true;
currentInVerse = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_verse}' || trimmed === '{eov}') {
inVerse = false;
currentInVerse = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{start_verse_num}') {
inVerseNum = true;
currentInVerseNum = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_verse_num}') {
inVerseNum = false;
currentInVerseNum = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
}
let classes = ['editor-line'];
if (currentInChorus) classes.push('chorus');
if (currentInVerse) classes.push('verse');
if (currentInVerseNum) classes.push('verse-num');
if (isTagLine) classes.push('tag-line');
const finalContent = processedContent || '&#8203;';
htmlLines.push(`<div class="${classes.join(' ')}">${finalContent}</div>`);
}
return htmlLines.join('\n');
}
get parsedSections(): ParsedSection[] { get parsedSections(): ParsedSection[] {
return this.lyricsParser.parseAccordi(this.content); const sections = this.lyricsParser.parseAccordi(this.content);
if (this.showChordsPreview && this.transposeAmount !== 0) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount);
}
return sections;
}
transposeUp() {
this.transposeAmount = (this.transposeAmount + 1) > 12 ? -11 : this.transposeAmount + 1;
}
transposeDown() {
this.transposeAmount = (this.transposeAmount - 1) < -12 ? 11 : this.transposeAmount - 1;
}
setDefaultTonalita() {
if (this.transposeAmount === 0 || !this.content) return;
const chordRegex = /\[([^\]]+)\]/g;
this.content = this.content.replace(chordRegex, (match, chord) => {
const transposed = this.lyricsParser.transposeChord(chord, this.transposeAmount);
return `[${transposed}]`;
});
this.transposeAmount = 0;
}
isAudioLoaded(): boolean {
return !!this.youtubeLink && this.cantiService.getYoutubeId(this.youtubeLink) !== null;
}
loadedYoutubeLink: string = '';
loadEditorAudio() {
if (!this.youtubeLink) return;
const videoId = this.cantiService.getYoutubeId(this.youtubeLink);
if (videoId) {
this.youtubePlayerService.initPlayer(this.editId || 'editing_song', 0, undefined, undefined, this.youtubeLink);
this.loadedYoutubeLink = this.youtubeLink;
}
}
togglePlayPause() {
const currentId = this.editId || 'editing_song';
const isLoaded = this.youtubePlayerService.currentCantoId() === currentId && this.loadedYoutubeLink === this.youtubeLink;
if (!isLoaded && this.youtubeLink) {
this.loadEditorAudio();
} else {
this.youtubePlayerService.togglePlayPause();
}
}
onSeek(event: any) {
const value = event.detail.value;
this.youtubePlayerService.seekTo(value);
}
formatSeconds(seconds: number): string {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
} }
toggleChordsPreview() { toggleChordsPreview() {
@@ -48,6 +193,8 @@ export class ProposeCantoPage implements OnInit {
youtubeLink: string = ''; youtubeLink: string = '';
selectedLiturgico: number[] = []; selectedLiturgico: number[] = [];
selectedTematico: number[] = []; selectedTematico: number[] = [];
durata: string = '';
bpm: number | null = null;
get sortedLiturgico() { get sortedLiturgico() {
return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name)); return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name));
@@ -77,31 +224,31 @@ export class ProposeCantoPage implements OnInit {
groupedChords = [ groupedChords = [
{ {
root: 'DO', root: 'DO',
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7'] chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DO4']
}, },
{ {
root: 'RE', root: 'RE',
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7'] chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'RE4']
}, },
{ {
root: 'MI', root: 'MI',
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim'] chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MI4']
}, },
{ {
root: 'FA', root: 'FA',
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7'] chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FA-7', 'FA4']
}, },
{ {
root: 'SOL', root: 'SOL',
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7'] chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOL-7', 'SOL4']
}, },
{ {
root: 'LA', root: 'LA',
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LAmaj7', 'LA4', 'LAdim', 'LAm7'] chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LA4']
}, },
{ {
root: 'SI', root: 'SI',
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim'] chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SI4']
} }
]; ];
@@ -152,6 +299,8 @@ export class ProposeCantoPage implements OnInit {
this.author = song.autore || ''; this.author = song.autore || '';
this.youtubeLink = song.link_youtube || ''; this.youtubeLink = song.link_youtube || '';
this.content = song.accordi || song.testo || ''; this.content = song.accordi || song.testo || '';
this.durata = song.durata || '';
this.bpm = song.bpm !== undefined ? song.bpm : null;
// Pre-populate liturgico and tematico lists // Pre-populate liturgico and tematico lists
const litIds = this.cantiService.indiceLiturgico().map(m => m.id); const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
@@ -164,8 +313,12 @@ export class ProposeCantoPage implements OnInit {
}); });
} }
ngOnDestroy() {
this.youtubePlayerService.stop();
}
async insertText(tag: string) { async insertText(tag: string) {
const input = await this.contentTextarea.getInputElement(); const input = this.nativeTextarea.nativeElement;
const start = input.selectionStart || 0; const start = input.selectionStart || 0;
const end = input.selectionEnd || 0; const end = input.selectionEnd || 0;
@@ -181,6 +334,19 @@ export class ProposeCantoPage implements OnInit {
this.insertText(`[${chord}]`); this.insertText(`[${chord}]`);
} }
toggleRawPasteMode() {
this.isRawPasteModeActive = !this.isRawPasteModeActive;
// Show a toast indicating whether the Raw Paste mode has been activated or deactivated
this.toastController.create({
message: this.isRawPasteModeActive
? 'Modalità Incolla Raw ATTIVA: incolla liberamente senza filtri'
: 'Modalità Incolla Raw DISATTIVA: i filtri automatici sono attivi',
duration: 2500,
color: this.isRawPasteModeActive ? 'warning' : 'primary'
}).then(toast => toast.present());
}
undo() { undo() {
if (this.undoStack.length > 0) { if (this.undoStack.length > 0) {
const previous = this.undoStack.pop(); const previous = this.undoStack.pop();
@@ -441,6 +607,10 @@ export class ProposeCantoPage implements OnInit {
} }
async onPaste(event: ClipboardEvent) { async onPaste(event: ClipboardEvent) {
if (this.isRawPasteModeActive) {
// In raw paste mode, we let the browser handle paste natively with no processing
return;
}
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
@@ -1554,7 +1724,9 @@ export class ProposeCantoPage implements OnInit {
link_youtube: this.youtubeLink, link_youtube: this.youtubeLink,
testo: this.content, testo: this.content,
accordi: this.content, accordi: this.content,
id_momenti: id_momenti id_momenti: id_momenti,
durata: this.durata || undefined,
bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined
}); });
// 2. Clone/convert remote playlist to local personal playlist // 2. Clone/convert remote playlist to local personal playlist
@@ -1587,7 +1759,9 @@ export class ProposeCantoPage implements OnInit {
link_youtube: this.youtubeLink, link_youtube: this.youtubeLink,
testo: this.content, testo: this.content,
accordi: this.content, // Save to both fields for compatibility accordi: this.content, // Save to both fields for compatibility
id_momenti: id_momenti id_momenti: id_momenti,
durata: this.durata || undefined,
bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined
}); });
if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) { if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) {
+20
View File
@@ -118,6 +118,14 @@
</ion-label> </ion-label>
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle> <ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
</ion-item> </ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="musical-notes-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Visualizza durata, bpm e tonalità</h2>
<p class="settings-item-subtitle">Mostra queste info sotto autore / titolo</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.showDurationBpmTonality()" (ionChange)="settingsService.toggleShowDurationBpmTonality()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon> <ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font"> <ion-label class="outfit-font">
@@ -273,6 +281,18 @@
</div> </div>
</div> </div>
<!-- Importa Playlist -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none" button (click)="importPlaylistViaQr()">
<ion-icon name="qr-code-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Importa Playlist via QR</h2>
<p class="settings-item-subtitle">Inquadra il QR di una playlist condivisa per importarla</p>
</ion-label>
<ion-icon name="chevron-forward-outline" slot="end" color="medium" style="font-size: 1rem;"></ion-icon>
</ion-item>
</div>
<!-- Invio Dati Statistici --> <!-- Invio Dati Statistici -->
<div class="settings-group glass ion-margin-bottom"> <div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
+19 -2
View File
@@ -77,6 +77,19 @@ export class SettingsPage {
} }
} }
async importPlaylistViaQr() {
const modal = await this.modalCtrl.create({
component: QrScannerComponent
});
await modal.present();
const { data } = await modal.onWillDismiss();
if (data) {
// Naviga alla home passando il dato via state per il processing multi-scopo
this.router.navigate(['/home'], { state: { scannedQrData: data } });
}
}
async manualRestore() { async manualRestore() {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Ripristina con Codice', header: 'Ripristina con Codice',
@@ -159,7 +172,9 @@ export class SettingsPage {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
})); }));
if (customSongs.length > 0) { if (customSongs.length > 0) {
@@ -347,7 +362,9 @@ export class SettingsPage {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
})); }));
this.myCantiService.myCanti.set(customSongs); this.myCantiService.myCanti.set(customSongs);
+59 -21
View File
@@ -1,7 +1,8 @@
import { Injectable, signal, inject, effect } from '@angular/core'; import { Injectable, signal, inject, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Storage } from '@ionic/storage-angular'; import { Storage } from '@ionic/storage-angular';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../environments/environment';
export interface Suggestion { export interface Suggestion {
id_canto?: number; id_canto?: number;
@@ -127,7 +128,18 @@ export class CantiLettureService {
const savedDate = localStorage.getItem('selected-mass-date'); const savedDate = localStorage.getItem('selected-mass-date');
if (savedDate) { if (savedDate) {
this.selectedMassDate.set(savedDate); const masses = this.availableMasses();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
// Only restore savedDate if it exists in available masses and is not outdated
const isValidAndNotPast = masses.some(m => m.date === savedDate && m.date >= todayStr);
if (isValidAndNotPast) {
this.selectedMassDate.set(savedDate);
}
} }
// Fetch fresh data // Fetch fresh data
@@ -137,24 +149,45 @@ export class CantiLettureService {
async fetchData() { async fetchData() {
try { try {
let fetchedData: CantiLettureData | null = null; let fetchedData: CantiLettureData | null = null;
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
const isProduction = window.location.hostname.includes('canticristiani.it'); const isProduction = window.location.hostname.includes('canticristiani.it');
const primaryUrl = isProduction const primaryUrl = isProduction
? `${window.location.origin}${this.SECURE_JSON_URL}` ? `${window.location.origin}${this.SECURE_JSON_URL}`
: 'https://www.canticristiani.it/api/cantiletture.json'; : 'https://www.canticristiani.it/api/cantiletture.json';
const fallbackUrl = isProduction
? 'https://www.canticristiani.it/api/cantiletture.json'
: this.JSON_URL;
// 1. Try static JSON endpoint first
try { try {
console.log('Fetching mass data from primary URL:', primaryUrl); console.log('Fetching mass data from primary URL:', primaryUrl);
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`)); const res = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
if (res && res.masses && res.week_end && res.week_end >= todayStr) {
fetchedData = res;
} else {
console.warn('Primary JSON data is missing or out of date:', res?.week_end);
}
} catch (err) { } catch (err) {
console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err); console.warn('Primary fetch failed:', err);
}
// 2. Fallback to direct API endpoint using Basic Auth credentials
if (!fetchedData) {
try { try {
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`)); console.log('Fetching mass data from direct API:', this.JSON_URL);
} catch (fallbackErr) { const authUser = environment.apiAuthUser || 'canti';
console.error('Fallback fetch also failed:', fallbackErr); const authPass = environment.apiAuthPass || 'antani2026';
const headers = new HttpHeaders({
'Authorization': 'Basic ' + btoa(`${authUser}:${authPass}`)
});
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${this.JSON_URL}?t=${Date.now()}`, { headers }));
if (res && res.masses) {
fetchedData = res;
}
} catch (authErr) {
console.error('Direct API fetch failed:', authErr);
} }
} }
@@ -194,24 +227,29 @@ export class CantiLettureService {
this.availableMasses.set(massesList); this.availableMasses.set(massesList);
// Always select today's mass if available on load, else find closest future date
if (massesList.length > 0) { if (massesList.length > 0) {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`; const todayStr = `${year}-${month}-${day}`;
const match = massesList.find(m => m.date === todayStr);
if (match) { const currentSelected = this.selectedMassDate();
this.selectedMassDate.set(match.date); const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr);
} else {
// If today is not present, find the first date that is in the future if (!isCurrentValid) {
const futureMatch = massesList.find(m => m.date >= todayStr); const match = massesList.find(m => m.date === todayStr);
if (futureMatch) { if (match) {
this.selectedMassDate.set(futureMatch.date); this.selectedMassDate.set(match.date);
} else { } else {
// Otherwise fall back to the last available mass date (closest to today) // If today is not present, find the first date that is in the future
this.selectedMassDate.set(massesList[massesList.length - 1].date); const futureMatch = massesList.find(m => m.date >= todayStr);
if (futureMatch) {
this.selectedMassDate.set(futureMatch.date);
} else {
// Otherwise fall back to the last available mass date (closest to today)
this.selectedMassDate.set(massesList[massesList.length - 1].date);
}
} }
} }
} }
+2
View File
@@ -15,6 +15,8 @@ export interface Canto {
data_update?: string; data_update?: string;
nonValidato?: boolean; nonValidato?: boolean;
isPersonal?: boolean; isPersonal?: boolean;
durata?: string;
bpm?: number;
} }
export interface Indice { export interface Indice {
+3 -1
View File
@@ -187,7 +187,9 @@ export class ComunitaService {
id_momenti: [], id_momenti: [],
data_update: cp.data_update || '', data_update: cp.data_update || '',
nonValidato: Number(cp.stato) === 10, nonValidato: Number(cp.stato) === 10,
isPersonal: true isPersonal: true,
durata: cp.durata || '',
bpm: cp.bpm !== undefined && cp.bpm !== null ? Number(cp.bpm) : undefined
})); }));
this.comunitaCode.set(trimmedCode); this.comunitaCode.set(trimmedCode);
+102 -72
View File
@@ -9,111 +9,106 @@ export class FaceDetectorService {
public isTilted = signal<boolean>(false); public isTilted = signal<boolean>(false);
private stream: MediaStream | null = null; private stream: MediaStream | null = null;
private camera: any = null; private animFrameId: number | null = null;
private faceMesh: any = null; private faceMesh: any = null;
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null; private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
// Gesture state machine // Gesture state machine
private tiltStartTime: number = 0; private tiltStartTime: number = 0;
private inCooldown: boolean = false; private inCooldown: boolean = false;
private readonly TILT_THRESHOLD = 15; // Degrees to trigger next/prev page private lastTriggerTime: number = 0;
private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability)
private readonly TILT_HOLD_MS = 300; // How long to hold the tilt private readonly TILT_HOLD_MS = 300; // How long to hold the tilt
private readonly RETURN_THRESHOLD = 6; // Degrees to reset cooldown private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown
private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms
constructor() {} constructor() {}
/** /**
* Loads MediaPipe scripts dynamically if not already loaded. * Loads MediaPipe script dynamically if not already loaded.
*/ */
private loadScripts(): Promise<void> { private loadScripts(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if ((window as any).FaceMesh && (window as any).Camera) { if ((window as any).FaceMesh) {
resolve(); resolve();
return; return;
} }
const cameraScript = document.createElement('script');
cameraScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js';
cameraScript.crossOrigin = 'anonymous';
const faceMeshScript = document.createElement('script'); const faceMeshScript = document.createElement('script');
faceMeshScript.src = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js'; faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
faceMeshScript.crossOrigin = 'anonymous'; faceMeshScript.onload = () => resolve();
cameraScript.onload = () => {
document.head.appendChild(faceMeshScript);
};
faceMeshScript.onload = () => {
resolve();
};
cameraScript.onerror = (err) => reject(err);
faceMeshScript.onerror = (err) => reject(err); faceMeshScript.onerror = (err) => reject(err);
document.head.appendChild(cameraScript); document.head.appendChild(faceMeshScript);
}); });
} }
/** /**
* Starts camera capture and face mesh tracking. * Starts or re-binds camera capture and face mesh tracking.
* If a stream is already active (e.g. navigating between songs on iPad), reuses it
* to avoid triggering repeated permission prompts on iOS/Safari.
*/ */
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> { async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> {
if (this.isCameraActive()) return;
this.onTiltCallback = onTilt; this.onTiltCallback = onTilt;
try { try {
await this.loadScripts(); await this.loadScripts();
// Request camera permissions and stream const isStreamActive = this.stream &&
this.stream = await navigator.mediaDevices.getUserMedia({ this.stream.active &&
video: { this.stream.getVideoTracks().some(track => track.readyState === 'live');
width: { ideal: 320 },
height: { ideal: 240 }, if (!isStreamActive) {
facingMode: 'user' // Request camera permissions and stream ONCE
}, this.stream = await navigator.mediaDevices.getUserMedia({
audio: false video: {
}); width: { ideal: 320 },
height: { ideal: 240 },
facingMode: 'user'
},
audio: false
});
}
videoElement.srcObject = this.stream; videoElement.srcObject = this.stream;
videoElement.setAttribute('playsinline', 'true'); videoElement.setAttribute('playsinline', 'true');
(videoElement as any).playsInline = true;
videoElement.muted = true; videoElement.muted = true;
videoElement.play();
const FaceMeshLib = (window as any).FaceMesh; try {
const CameraLib = (window as any).Camera; await videoElement.play();
} catch (playErr) {
if (!FaceMeshLib || !CameraLib) { console.warn('[FaceDetector] Video play warning:', playErr);
throw new Error('MediaPipe libraries failed to initialize.');
} }
this.faceMesh = new FaceMeshLib({ if (!this.faceMesh) {
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}` const FaceMeshLib = (window as any).FaceMesh;
}); if (!FaceMeshLib) {
throw new Error('MediaPipe FaceMesh library failed to initialize.');
}
this.faceMesh.setOptions({ this.faceMesh = new FaceMeshLib({
maxNumFaces: 1, locateFile: (file: string) => `assets/mediapipe/${file}`
refineLandmarks: false, });
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.6
});
this.faceMesh.onResults((results: any) => { this.faceMesh.setOptions({
this.processLandmarks(results); maxNumFaces: 1,
}); refineLandmarks: false,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.6
});
this.camera = new CameraLib(videoElement, { this.faceMesh.onResults((results: any) => {
onFrame: async () => { this.processLandmarks(results);
if (this.isCameraActive() && this.faceMesh) { });
await this.faceMesh.send({ image: videoElement }); }
}
},
width: 320,
height: 240
});
this.isCameraActive.set(true); this.isCameraActive.set(true);
await this.camera.start();
// Start custom frame processing loop
this.stopFrameLoop();
this.startFrameLoop(videoElement);
console.log('[FaceDetector] Face tracking started successfully.'); console.log('[FaceDetector] Face tracking started successfully.');
} catch (err) { } catch (err) {
console.error('[FaceDetector] Failed to start face tracking:', err); console.error('[FaceDetector] Failed to start face tracking:', err);
@@ -122,6 +117,38 @@ export class FaceDetectorService {
} }
} }
private stopFrameLoop() {
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
}
private startFrameLoop(videoElement: HTMLVideoElement) {
let lastFrameTime = 0;
const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM
const processFrame = async () => {
if (!this.isCameraActive()) return;
const now = Date.now();
if (now - lastFrameTime >= FRAME_INTERVAL_MS && this.faceMesh && videoElement && videoElement.readyState >= 2) {
lastFrameTime = now;
try {
await this.faceMesh.send({ image: videoElement });
} catch (err) {
console.warn('[FaceDetector] Error processing frame:', err);
}
}
if (this.isCameraActive()) {
this.animFrameId = requestAnimationFrame(processFrame);
}
};
this.animFrameId = requestAnimationFrame(processFrame);
}
/** /**
* Processes landmarks to calculate head tilt angle. * Processes landmarks to calculate head tilt angle.
*/ */
@@ -155,7 +182,7 @@ export class FaceDetectorService {
if (absAngle > this.TILT_THRESHOLD) { if (absAngle > this.TILT_THRESHOLD) {
this.isTilted.set(true); this.isTilted.set(true);
if (!this.inCooldown) { if (!this.inCooldown && (Date.now() - this.lastTriggerTime > this.TRIGGER_COOLDOWN_MS)) {
if (this.tiltStartTime === 0) { if (this.tiltStartTime === 0) {
this.tiltStartTime = Date.now(); this.tiltStartTime = Date.now();
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) { } else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
@@ -167,6 +194,7 @@ export class FaceDetectorService {
} }
this.inCooldown = true; this.inCooldown = true;
this.tiltStartTime = 0; this.tiltStartTime = 0;
this.lastTriggerTime = Date.now();
} }
} }
} else { } else {
@@ -181,21 +209,23 @@ export class FaceDetectorService {
} }
/** /**
* Stops camera capture and releases face mesh resources. * Pauses the frame tracking loop without killing the underlying media stream hardware.
*/
pause() {
this.stopFrameLoop();
this.isCameraActive.set(false);
}
/**
* Stops camera capture and releases face mesh & hardware stream resources.
*/ */
stop() { stop() {
this.isCameraActive.set(false); this.pause();
this.currentTiltAngle.set(0); this.currentTiltAngle.set(0);
this.isTilted.set(false); this.isTilted.set(false);
this.inCooldown = false; this.inCooldown = false;
this.tiltStartTime = 0; this.tiltStartTime = 0;
this.lastTriggerTime = 0;
if (this.camera) {
try {
this.camera.stop();
} catch (e) {}
this.camera = null;
}
if (this.stream) { if (this.stream) {
this.stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
@@ -210,6 +240,6 @@ export class FaceDetectorService {
} }
this.onTiltCallback = null; this.onTiltCallback = null;
console.log('[FaceDetector] Face tracking stopped.'); console.log('[FaceDetector] Face tracking fully stopped.');
} }
} }
@@ -126,4 +126,23 @@ Abba Padre!
expect(sections[1].type).toBe('chorus'); expect(sections[1].type).toBe('chorus');
expect(sections[2].type).toBe('verse'); 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(['|', '|', '|', '|']);
});
}); });
+48 -1
View File
@@ -194,9 +194,13 @@ export class LyricsParserService {
* sei Re Gesù[SOL] text "sei Re Gesù" then chord "SOL" with empty text * sei Re Gesù[SOL] text "sei Re Gesù" then chord "SOL" with empty text
*/ */
parseChordLine(line: string): ParsedLine { parseChordLine(line: string): ParsedLine {
// Treat vertical bars (|) as chords instead of text
let prepared = line.replace(/\[\|\]/g, '|');
prepared = prepared.replace(/\|/g, '[|]');
const segments: ChordSegment[] = []; const segments: ChordSegment[] = [];
// Clean up non-breaking spaces // Clean up non-breaking spaces
const cleaned = line.replace(/\u00a0/g, ' ').trim(); const cleaned = prepared.replace(/\u00a0/g, ' ').trim();
// Regex to match [CHORD] tags and text between them // Regex to match [CHORD] tags and text between them
const chordRegex = /\[([^\]]+)\]/g; const chordRegex = /\[([^\]]+)\]/g;
@@ -251,6 +255,7 @@ export class LyricsParserService {
*/ */
transposeChord(chord: string, semitones: number): string { transposeChord(chord: string, semitones: number): string {
if (!chord) return chord; if (!chord) return chord;
if (chord === '|') return '|';
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm' // 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) => { chord = chord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
@@ -340,6 +345,11 @@ export class LyricsParserService {
isContiguousNext(segments: ChordSegment[], index: number): boolean { isContiguousNext(segments: ChordSegment[], index: number): boolean {
if (!segments || index >= segments.length - 1) return false; if (!segments || index >= segments.length - 1) return false;
// Se sia il segmento corrente che il successivo hanno un accordo, non sono contigui (vogliamo dello spazio tra loro)
if (segments[index].chord && segments[index + 1].chord) {
return false;
}
// Find the next segment with non-empty text // Find the next segment with non-empty text
let nextWithText: ChordSegment | null = null; let nextWithText: ChordSegment | null = null;
for (let i = index + 1; i < segments.length; i++) { for (let i = index + 1; i < segments.length; i++) {
@@ -362,5 +372,42 @@ export class LyricsParserService {
return endsWithNonSpace && startsWithNonSpace; return endsWithNonSpace && startsWithNonSpace;
} }
/**
* Deduce the musical key (tonality) of the song by looking at the first chord.
*/
deduceTonality(raw: string): string | null {
if (!raw) return null;
const matches = [...raw.matchAll(/\[([^\]]+)\]/g)];
if (matches.length === 0) return null;
// Get the first chord
let firstChord = matches[0][1].trim();
if (firstChord.includes('/')) {
firstChord = firstChord.split('/')[0].trim();
}
// Normalize minor indicators (uppercase M/N to m)
firstChord = firstChord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
return p1 + (p2 || '') + 'm';
});
// Find the root chord name matching the scales
const possibleRoots = [...this.scale, ...this.flatScale].sort((a, b) => b.length - a.length);
let root = '';
const upperChord = firstChord.toUpperCase();
for (const r of possibleRoots) {
if (upperChord.startsWith(r.toUpperCase())) {
root = r;
break;
}
}
if (!root) return null;
// Check if it is a minor chord
const isMinor = firstChord.toLowerCase().includes('m') || firstChord.includes('-');
return root + (isMinor ? 'm' : '');
}
} }
+20 -8
View File
@@ -17,17 +17,21 @@ export class MyCantiService {
private _storage: Storage | null = null; private _storage: Storage | null = null;
public myCanti = signal<Canto[]>([]); public myCanti = signal<Canto[]>([]);
private initPromise!: Promise<void>;
constructor() { constructor() {
this.init(); this.initPromise = this.init();
} }
async init() { async init(): Promise<void> {
this._storage = this.cantiService.getStorage(); this._storage = this.cantiService.getStorage();
if (!this._storage) { if (!this._storage) {
// If CantiService hasn't initialized storage yet, wait a bit return new Promise<void>((resolve) => {
setTimeout(() => this.init(), 500); setTimeout(async () => {
return; await this.init();
resolve();
}, 500);
});
} }
const saved = await this._storage.get('my-canti'); const saved = await this._storage.get('my-canti');
if (saved) { if (saved) {
@@ -36,6 +40,7 @@ export class MyCantiService {
} }
async saveCanto(canto: Partial<Canto>): Promise<Canto> { async saveCanto(canto: Partial<Canto>): Promise<Canto> {
await this.initPromise;
const current = this.myCanti(); const current = this.myCanti();
let updated: Canto[]; let updated: Canto[];
let targetCanto: Canto; let targetCanto: Canto;
@@ -51,7 +56,9 @@ export class MyCantiService {
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi, accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
autore: canto.autore !== undefined ? canto.autore : c.autore, autore: canto.autore !== undefined ? canto.autore : c.autore,
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube, link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
id_momenti: canto.id_momenti || c.id_momenti id_momenti: canto.id_momenti || c.id_momenti,
durata: canto.durata !== undefined ? canto.durata : c.durata,
bpm: canto.bpm !== undefined ? canto.bpm : c.bpm
}; };
return targetCanto; return targetCanto;
} }
@@ -69,7 +76,9 @@ export class MyCantiService {
accordi: canto.accordi, accordi: canto.accordi,
autore: canto.autore, autore: canto.autore,
link_youtube: canto.link_youtube, link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || [] id_momenti: canto.id_momenti || [],
durata: canto.durata,
bpm: canto.bpm
}; };
updated.push(targetCanto); updated.push(targetCanto);
} }
@@ -83,7 +92,9 @@ export class MyCantiService {
accordi: canto.accordi, accordi: canto.accordi,
autore: canto.autore, autore: canto.autore,
link_youtube: canto.link_youtube, link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || [] id_momenti: canto.id_momenti || [],
durata: canto.durata,
bpm: canto.bpm
}; };
updated = [...current, targetCanto]; updated = [...current, targetCanto];
} }
@@ -108,6 +119,7 @@ export class MyCantiService {
} }
async deleteCanto(id: string) { async deleteCanto(id: string) {
await this.initPromise;
const updated = this.myCanti().filter(c => c.id !== id); const updated = this.myCanti().filter(c => c.id !== id);
this.myCanti.set(updated); this.myCanti.set(updated);
await this._storage?.set('my-canti', updated); await this._storage?.set('my-canti', updated);
+682 -121
View File
@@ -3,9 +3,11 @@ import { Storage } from '@ionic/storage-angular';
import { Canto, CantiService } from './canti.service'; import { Canto, CantiService } from './canti.service';
import { ComunitaService } from './comunita.service'; import { ComunitaService } from './comunita.service';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { ToastController, AlertController } from '@ionic/angular'; import { ToastController, AlertController, ModalController } from '@ionic/angular';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { SharePlaylistQrModalComponent } from '../components/share-playlist-qr-modal/share-playlist-qr-modal.component';
import { MyCantiService } from './my-canti.service'; import { MyCantiService } from './my-canti.service';
import { LyricsParserService } from './lyrics-parser.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -13,9 +15,11 @@ import { MyCantiService } from './my-canti.service';
export class PlaylistService { export class PlaylistService {
private storage = inject(Storage); private storage = inject(Storage);
private cantiService = inject(CantiService); private cantiService = inject(CantiService);
private lyricsParser = inject(LyricsParserService);
private comunitaService = inject(ComunitaService); private comunitaService = inject(ComunitaService);
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
private alertCtrl = inject(AlertController); private alertCtrl = inject(AlertController);
private modalCtrl = inject(ModalController);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private injector = inject(Injector); private injector = inject(Injector);
private myCantiService!: MyCantiService; private myCantiService!: MyCantiService;
@@ -29,10 +33,12 @@ export class PlaylistService {
public activeListIds = signal<string[]>([]); public activeListIds = signal<string[]>([]);
public activeListName = signal<string | null>(null); public activeListName = signal<string | null>(null);
public activePlaylistId = signal<string | null>(null); public activePlaylistId = signal<string | null>(null);
public filteredListIds = signal<string[]>([]);
public remotePlaylist = signal<any | null>(null); public remotePlaylist = signal<any | null>(null);
public remoteCustomSongs = signal<any[]>([]); public remoteCustomSongs = signal<any[]>([]);
public remoteShareCanti = signal<Canto[]>([]); public remoteShareCanti = signal<Canto[]>([]);
public hasRemotePlaylistUpdate = signal<boolean>(false);
private _storage: Storage | null = null; private _storage: Storage | null = null;
private initPromise!: Promise<void>; private initPromise!: Promise<void>;
@@ -43,10 +49,7 @@ export class PlaylistService {
return []; return [];
} }
return this.comunitaService.comunitaScalette().map(s => ({ return this.comunitaService.comunitaScalette().map(s => ({
id: s.id, ...s,
name: s.name,
ids: s.ids,
createdAt: s.date,
isComunita: true isComunita: true
})); }));
}); });
@@ -83,21 +86,23 @@ export class PlaylistService {
getPlaylistsStorageKey(): string { getPlaylistsStorageKey(): string {
const code = this.comunitaService.comunitaCode(); const code = this.comunitaService.comunitaCode();
const isCommunityActive = this.comunitaService.isFilterActive(); const active = this.comunitaService.isFilterActive();
if (code && isCommunityActive) { if (code && active) {
return `playlists_comunita_${code}`; return `playlists_${code}`;
} }
return 'playlists'; return 'playlists';
} }
async init() { async init(): Promise<void> {
const storage = await this.storage.create(); this._storage = this.cantiService.getStorage();
this._storage = storage; if (!this._storage) {
await this.loadPlaylistsForCurrentContext(); return new Promise<void>((resolve) => {
} setTimeout(async () => {
await this.init();
async loadPlaylistsForCurrentContext() { resolve();
if (!this._storage) return; }, 500);
});
}
const key = this.getPlaylistsStorageKey(); const key = this.getPlaylistsStorageKey();
const saved = await this._storage.get(key); const saved = await this._storage.get(key);
this.playlists.set(saved || []); this.playlists.set(saved || []);
@@ -126,6 +131,73 @@ export class PlaylistService {
} }
} }
async loadPlaylistsForCurrentContext() {
await this.initPromise;
const key = this.getPlaylistsStorageKey();
const saved = await this._storage?.get(key);
this.playlists.set(saved || []);
const lastKey = `lastPlaylist_${key}`;
const last = await this._storage?.get(lastKey);
this.lastPlaylist.set(last || null);
// Carica la playlist remota persistita e i relativi canti personalizzati
const remotePl = await this._storage?.get('remote_playlist');
if (remotePl) {
this.remotePlaylist.set(remotePl);
}
const remoteSongs = await this._storage?.get('remote_custom_songs');
if (remoteSongs) {
this.remoteCustomSongs.set(remoteSongs);
}
const remoteShareSongs = await this._storage?.get('remote_share_canti');
if (remoteShareSongs) {
this.remoteShareCanti.set(remoteShareSongs);
}
// Aggiorna in background la playlist remota per sincronizzare eventuali modifiche
if (remotePl) {
this.refreshRemotePlaylist();
}
}
async checkForRemotePlaylistUpdates() {
const remotePl = this.remotePlaylist();
if (!remotePl) {
this.hasRemotePlaylistUpdate.set(false);
return;
}
const parts = remotePl.id.split('_');
if (parts.length < 3) return;
const uid = parts[1];
try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) return;
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
const playlists = remoteJson.filter((item: any) => item.momenti && item.momenti.includes('Playlist'));
const updatedPl = playlists.find((p: any) => `remote_${uid}_${p.id_canti}` === remotePl.id);
if (updatedPl) {
const serverIds = updatedPl.testo ? updatedPl.testo.split(',') : [];
const localIds = remotePl.ids || [];
const idsMatch = serverIds.length === localIds.length && serverIds.every((id: string, idx: number) => id === localIds[idx]);
const nameMatch = `[Remote] ${updatedPl.titolo}` === remotePl.name;
if (!idsMatch || !nameMatch) {
this.hasRemotePlaylistUpdate.set(true);
return;
}
}
}
this.hasRemotePlaylistUpdate.set(false);
} catch (e) {
console.warn('Failed to check for remote playlist updates:', e);
}
}
async refreshRemotePlaylist() { async refreshRemotePlaylist() {
const remotePl = this.remotePlaylist(); const remotePl = this.remotePlaylist();
if (!remotePl) return; if (!remotePl) return;
@@ -149,7 +221,9 @@ export class PlaylistService {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', link_youtube: item.link_youtube || '',
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
})); }));
const playlists = remoteJson const playlists = remoteJson
@@ -177,6 +251,12 @@ export class PlaylistService {
this.remoteCustomSongs.set(customSongs); this.remoteCustomSongs.set(customSongs);
await this._storage?.set('remote_playlist', updatedPl); await this._storage?.set('remote_playlist', updatedPl);
await this._storage?.set('remote_custom_songs', customSongs); await this._storage?.set('remote_custom_songs', customSongs);
this.hasRemotePlaylistUpdate.set(false);
if (this.activePlaylistId() === remotePl.id) {
this.activeListIds.set(updatedPl.ids);
this.activeListName.set(updatedPl.name);
}
console.log('[Remote-Sync] Remote playlist and custom songs updated successfully.'); console.log('[Remote-Sync] Remote playlist and custom songs updated successfully.');
} }
} }
@@ -328,15 +408,14 @@ export class PlaylistService {
this.syncLocalDataToServer(); this.syncLocalDataToServer();
} }
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number, zoom?: number) { async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number) {
await this.initPromise; await this.initPromise;
this.playlists.update(p => p.map(pl => { this.playlists.update(p => p.map(pl => {
if (pl.id === playlistId) { if (pl.id === playlistId) {
const songSettings = { ...(pl.songSettings || {}) }; const songSettings = { ...(pl.songSettings || {}) };
songSettings[songId] = { songSettings[songId] = {
tonalita, tonalita,
speed, speed
zoom: zoom !== undefined ? zoom : songSettings[songId]?.zoom
}; };
return { ...pl, songSettings }; return { ...pl, songSettings };
} }
@@ -414,7 +493,9 @@ export class PlaylistService {
accordi: c.accordi || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [], momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[], periodi: [] as string[],
testo: c.testo || '' testo: c.testo || '',
durata: c.durata || '',
bpm: c.bpm !== undefined ? c.bpm : null
}); });
}); });
@@ -428,7 +509,9 @@ export class PlaylistService {
accordi: c.accordi || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [], momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[], periodi: [] as string[],
testo: c.testo || '' testo: c.testo || '',
durata: c.durata || '',
bpm: c.bpm !== undefined ? c.bpm : null
}); });
}); });
@@ -442,7 +525,9 @@ export class PlaylistService {
accordi: c.accordi || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map((id: any) => String(id)) || [], momenti: c.id_momenti?.map((id: any) => String(id)) || [],
periodi: [] as string[], periodi: [] as string[],
testo: c.testo || '' testo: c.testo || '',
durata: c.durata || '',
bpm: c.bpm !== undefined ? c.bpm : null
}); });
}); });
@@ -456,7 +541,9 @@ export class PlaylistService {
accordi: c.accordi || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map((id: any) => String(id)) || [], momenti: c.id_momenti?.map((id: any) => String(id)) || [],
periodi: [] as string[], periodi: [] as string[],
testo: c.testo || '' testo: c.testo || '',
durata: c.durata || '',
bpm: c.bpm !== undefined ? c.bpm : null
}); });
}); });
@@ -583,108 +670,26 @@ export class PlaylistService {
const activeId = this.activePlaylistId() || Date.now().toString(); const activeId = this.activePlaylistId() || Date.now().toString();
const isRemote = activeId.startsWith('remote_'); const isRemote = activeId.startsWith('remote_');
const buttons: any[] = [ let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
{ if (isRemote) {
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)', let remoteUid = uid;
handler: () => { let remotePid = activeId;
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`; const parts = activeId.split('_');
if (isRemote) { if (parts.length >= 3) {
let remoteUid = uid; remoteUid = parts[1];
let remotePid = activeId; remotePid = parts[2];
const parts = activeId.split('_');
if (parts.length >= 3) {
remoteUid = parts[1];
remotePid = parts[2];
}
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
}
this.executeShare(shareLink, name);
}
} }
]; shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
if (!isRemote) {
buttons.push({
text: 'Modifica (Collaborazione / Backup)',
handler: () => {
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
this.executeShare(shareLink, name + ' (Editor)');
}
});
} }
buttons.push({ const modal = await this.modalCtrl.create({
text: 'Annulla', component: SharePlaylistQrModalComponent,
role: 'cancel' componentProps: {
}); playlistName: name,
shareLink: shareLink
const alert = await this.alertCtrl.create({
header: 'Condividi Playlist',
message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:',
buttons: buttons
});
await alert.present();
}
private async executeShare(shareLink: string, name: string) {
// Generate QR using the link
const qrImage = await QRCode.toDataURL(shareLink, {
width: 400,
margin: 2,
color: {
dark: '#2d3436',
light: '#ffffff'
} }
}); });
await modal.present();
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
const res = await fetch(qrImage);
const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' });
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
});
} else {
// Fallback to simple share text or copy/download
if (navigator.share) {
await navigator.share({
title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
});
} else {
// Fallback: copia il link negli appunti e scarica l'immagine del QR
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
const toast = await this.toastCtrl.create({
message: 'Link playlist copiato negli appunti! QR Code scaricato.',
duration: 3000,
color: 'success'
});
await toast.present();
}
} catch (clipErr) {
console.warn('Failed to copy link to clipboard:', clipErr);
}
const link = document.createElement('a');
link.href = qrImage;
link.download = fileName;
link.click();
}
}
} catch (err) {
console.error('Share failed', err);
}
} }
async loadPlaylists() { async loadPlaylists() {
@@ -710,4 +715,560 @@ export class PlaylistService {
this.remoteShareCanti.set(updated); this.remoteShareCanti.set(updated);
await this._storage?.set('remote_share_canti', updated); await this._storage?.set('remote_share_canti', updated);
} }
public printPlaylist(playlistName: string, songs: any[], showChords: boolean, transpositions?: { [songId: string]: number }) {
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Impossibile aprire la finestra di stampa. Abilita i popup nel browser.');
return;
}
if (!this.myCantiService) {
this.myCantiService = this.injector.get(MyCantiService);
}
const titleHtml = playlistName ? `
<div class="cover-page">
<div class="cover-border">
<h1>Playlist: ${playlistName}</h1>
<p class="meta">Data: ${new Date().toLocaleDateString('it-IT')} &nbsp;&bull;&nbsp; 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();
}
} }
+52
View File
@@ -41,12 +41,18 @@ export class SettingsService {
/** Visualizza data update sotto autore nella lista canti: true = attivo */ /** Visualizza data update sotto autore nella lista canti: true = attivo */
public showUpdateDate = signal<boolean>(true); public showUpdateDate = signal<boolean>(true);
/** Visualizza durata, bpm e tonalità sotto autore / titolo: true = attivo */
public showDurationBpmTonality = signal<boolean>(true);
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */ /** Attiva autoscroll standard nel dettaglio canto: true = attivo */
public enableStandardAutoscroll = signal<boolean>(false); public enableStandardAutoscroll = signal<boolean>(false);
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */ /** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
public enableVisualAutoscroll = signal<boolean>(true); public enableVisualAutoscroll = signal<boolean>(true);
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
public cameraNavigationActive = signal<boolean>(false);
/** Preferenza notazione accordi: diesis o bemolle */ /** Preferenza notazione accordi: diesis o bemolle */
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
@@ -59,6 +65,9 @@ export class SettingsService {
/** Schermo nero per l'ascolto delle playlist in macchina: true = attivo */ /** Schermo nero per l'ascolto delle playlist in macchina: true = attivo */
public carModeBlackScreen = signal<boolean>(false); public carModeBlackScreen = signal<boolean>(false);
/** Global zoom/font size factor for song presentation */
public globalZoom = signal<number>(1.0);
/** Identificativo utente univoco per la gestione delle comunità */ /** Identificativo utente univoco per la gestione delle comunità */
public userUuid = signal<string>(''); public userUuid = signal<string>('');
@@ -131,6 +140,8 @@ export class SettingsService {
this.deferredPrompt.set(e); this.deferredPrompt.set(e);
// Update UI notify the user they can install the PWA // Update UI notify the user they can install the PWA
this.showInstallButton.set(true); this.showInstallButton.set(true);
// Se scatta l'evento di installazione, l'app NON è attualmente installata
localStorage.setItem('pwa-installed', 'false');
}); });
window.addEventListener('appinstalled', () => { window.addEventListener('appinstalled', () => {
@@ -152,6 +163,7 @@ export class SettingsService {
localStorage.setItem('invio-dati-statistici', 'false'); localStorage.setItem('invio-dati-statistici', 'false');
localStorage.setItem('show-tags-in-list', 'true'); localStorage.setItem('show-tags-in-list', 'true');
localStorage.setItem('show-update-date', 'true'); localStorage.setItem('show-update-date', 'true');
localStorage.setItem('show-duration-bpm-tonality', 'true');
localStorage.setItem('enable-standard-autoscroll', 'false'); localStorage.setItem('enable-standard-autoscroll', 'false');
localStorage.setItem('enable-visual-autoscroll', 'true'); localStorage.setItem('enable-visual-autoscroll', 'true');
localStorage.setItem('chord-notation-preference', 'diesis'); localStorage.setItem('chord-notation-preference', 'diesis');
@@ -222,6 +234,13 @@ export class SettingsService {
this.showUpdateDate.set(true); this.showUpdateDate.set(true);
} }
const savedShowDurBpmTon = localStorage.getItem('show-duration-bpm-tonality');
if (savedShowDurBpmTon !== null) {
this.showDurationBpmTonality.set(savedShowDurBpmTon === 'true');
} else {
this.showDurationBpmTonality.set(true);
}
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll'); const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
if (savedStandardAutoscroll !== null) { if (savedStandardAutoscroll !== null) {
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true'); this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
@@ -264,6 +283,14 @@ export class SettingsService {
this.carModeBlackScreen.set(false); this.carModeBlackScreen.set(false);
} }
const savedGlobalZoom = localStorage.getItem('global-zoom');
if (savedGlobalZoom !== null) {
const parsed = parseFloat(savedGlobalZoom);
this.globalZoom.set(isNaN(parsed) ? 1.0 : parsed);
} else {
this.globalZoom.set(1.0);
}
// Sync browser fullscreen state with listeners (supporting vendor prefixes) // Sync browser fullscreen state with listeners (supporting vendor prefixes)
const updateFullscreenState = () => { const updateFullscreenState = () => {
const isFs = !!( const isFs = !!(
@@ -301,6 +328,10 @@ export class SettingsService {
localStorage.setItem('show-update-date', this.showUpdateDate().toString()); localStorage.setItem('show-update-date', this.showUpdateDate().toString());
}); });
effect(() => {
localStorage.setItem('show-duration-bpm-tonality', this.showDurationBpmTonality().toString());
});
effect(() => { effect(() => {
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString()); localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
}); });
@@ -325,6 +356,21 @@ export class SettingsService {
localStorage.setItem('car-mode-black-screen', this.carModeBlackScreen().toString()); localStorage.setItem('car-mode-black-screen', this.carModeBlackScreen().toString());
}); });
effect(() => {
localStorage.setItem('global-zoom', this.globalZoom().toString());
});
const savedCameraNavActive = localStorage.getItem('camera-navigation-active');
if (savedCameraNavActive !== null) {
this.cameraNavigationActive.set(savedCameraNavActive === 'true');
} else {
this.cameraNavigationActive.set(false);
}
effect(() => {
localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString());
});
effect(() => { effect(() => {
const active = this.keepScreenOn(); const active = this.keepScreenOn();
localStorage.setItem('keep-screen-on', active.toString()); localStorage.setItem('keep-screen-on', active.toString());
@@ -436,6 +482,12 @@ export class SettingsService {
localStorage.setItem('show-update-date', newValue.toString()); localStorage.setItem('show-update-date', newValue.toString());
} }
toggleShowDurationBpmTonality() {
const newValue = !this.showDurationBpmTonality();
this.showDurationBpmTonality.set(newValue);
localStorage.setItem('show-duration-bpm-tonality', newValue.toString());
}
toggleStandardAutoscroll() { toggleStandardAutoscroll() {
const newValue = !this.enableStandardAutoscroll(); const newValue = !this.enableStandardAutoscroll();
this.enableStandardAutoscroll.set(newValue); this.enableStandardAutoscroll.set(newValue);
+51 -19
View File
@@ -106,8 +106,8 @@ export class YoutubePlayerService {
}; };
} }
public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void) { public initPlayer(cantoId: string, startTime: number = 0, onEnded?: () => void, onError?: () => void, customYoutubeLink?: string) {
if (this.currentCantoId() === cantoId && this.player) { if (this.currentCantoId() === cantoId && this.player && !customYoutubeLink) {
this.onEndedCallback = onEnded || null; this.onEndedCallback = onEnded || null;
this.onErrorCallback = onError || null; this.onErrorCallback = onError || null;
@@ -123,36 +123,55 @@ export class YoutubePlayerService {
this.onEndedCallback = onEnded || null; this.onEndedCallback = onEnded || null;
this.onErrorCallback = onError || null; this.onErrorCallback = onError || null;
let canto = this.cantiService.getCantoById(cantoId); let videoId: string | null = null;
if (!canto) { if (customYoutubeLink) {
canto = this.myCantiService.myCanti().find(c => c.id === cantoId); videoId = this.cantiService.getYoutubeId(customYoutubeLink);
} else {
let canto = this.cantiService.getCantoById(cantoId);
if (!canto) {
canto = this.myCantiService.myCanti().find(c => c.id === cantoId);
}
if (!canto) {
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
}
if (!canto) {
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
}
if (!canto) {
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
}
if (canto) {
videoId = this.cantiService.getYoutubeId(canto.link_youtube);
}
} }
if (!canto) {
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
}
if (!canto) {
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 videoId = this.cantiService.getYoutubeId(canto.link_youtube);
if (!videoId) { if (!videoId) {
if (onError) onError(); if (onError) onError();
return; return;
} }
if (!(window as any).YT || !(window as any).YT.Player) { if (!(window as any).YT || !(window as any).YT.Player) {
setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError), 200); setTimeout(() => this.initPlayer(cantoId, startTime, onEnded, onError, customYoutubeLink), 200);
return; return;
} }
// Reuse existing player if possible // Reuse existing player if possible
if (this.player && this.player.loadVideoById) { if (this.player && this.player.loadVideoById) {
this.currentCantoId.set(cantoId); this.currentCantoId.set(cantoId);
this.mediaSessionService.updateMetadata(cantoId); if (customYoutubeLink) {
if ('mediaSession' in navigator && 'MediaMetadata' in window) {
try {
(navigator as any).mediaSession.metadata = new (window as any).MediaMetadata({
title: 'Anteprima Canto',
artist: 'Canti Cristiani',
album: 'Canti Cristiani',
artwork: [{ src: 'assets/icon/favicon.png', sizes: '512x512', type: 'image/png' }]
});
} catch (e) {}
}
} else {
this.mediaSessionService.updateMetadata(cantoId);
}
this.player.loadVideoById({ this.player.loadVideoById({
videoId: videoId, videoId: videoId,
startSeconds: startTime startSeconds: startTime
@@ -163,7 +182,20 @@ export class YoutubePlayerService {
this.destroyPlayer(); this.destroyPlayer();
this.currentCantoId.set(cantoId); this.currentCantoId.set(cantoId);
this.mediaSessionService.updateMetadata(cantoId); if (customYoutubeLink) {
if ('mediaSession' in navigator && 'MediaMetadata' in window) {
try {
(navigator as any).mediaSession.metadata = new (window as any).MediaMetadata({
title: 'Anteprima Canto',
artist: 'Canti Cristiani',
album: 'Canti Cristiani',
artwork: [{ src: 'assets/icon/favicon.png', sizes: '512x512', type: 'image/png' }]
});
} catch (e) {}
}
} else {
this.mediaSessionService.updateMetadata(cantoId);
}
this.player = new (window as any).YT.Player('global-yt-player-container', { this.player = new (window as any).YT.Player('global-yt-player-container', {
height: '1', height: '1',
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.06.18.1434'; export const VERSION = '2026.08.09.0826';
+23
View File
@@ -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.
+131
View File
@@ -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);
@@ -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
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -1,8 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="it" class="notranslate">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="google" content="notranslate"/>
<title>CantiCristiani</title> <title>CantiCristiani</title>
<base href="/"/> <base href="/"/>
@@ -73,7 +74,11 @@
if (options.percent !== undefined) { if (options.percent !== undefined) {
const pct = Math.min(100, Math.max(0, Math.round(options.percent))); const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
if (bar) bar.style.width = pct + '%'; if (bar) bar.style.width = pct + '%';
if (pctText) pctText.textContent = pct + '%'; if (bar && bar.parentElement) bar.parentElement.style.display = 'block';
if (pctText) {
pctText.style.display = 'block';
pctText.textContent = pct + '%';
}
} }
if (options.isRedirect) { if (options.isRedirect) {
@@ -82,7 +87,7 @@
if (phaseEl) phaseEl.style.display = 'none'; if (phaseEl) phaseEl.style.display = 'none';
if (versionEl) versionEl.style.display = 'none'; if (versionEl) versionEl.style.display = 'none';
if (titleEl) { if (titleEl) {
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device'; titleEl.textContent = 'Chiudi il browser è stata installata la app sul device';
titleEl.style.fontSize = '1.4rem'; titleEl.style.fontSize = '1.4rem';
titleEl.style.lineHeight = '1.5'; titleEl.style.lineHeight = '1.5';
} }