diff --git a/deploy_www.sh b/deploy_www.sh index e4dfbc0..0d0acb6 100755 --- a/deploy_www.sh +++ b/deploy_www.sh @@ -59,3 +59,9 @@ echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)" # --- Upload --- echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..." rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH" + +# --- Allineamento Cantiletture JSON --- +if [ -f "./allinealetture.sh" ]; then + echo "🔄 Sincronizzazione cantiletture.json su server..." + ./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito" +fi diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 66a2445..0bce36a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -304,30 +304,70 @@ export class AppComponent implements OnInit { console.log('[AppComponent] PWA appinstalled event caught.'); localStorage.setItem('pwa-installed', 'true'); this.isPwaInstalled.set(true); - this.isInstalling.set(false); - this.isRedirecting.set(true); this.showInstallOverlay.set(false); this.showRedirectOverlay.set(false); - if ((window as any).PwaLoader) { - (window as any).PwaLoader.show(); - (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.'); + // 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; } - setTimeout(() => { - window.location.href = this.protocolLink; - }, 1000); + // Su Android, l'evento 'appinstalled' scatta in 2-3 secondi, MA l'OS impiega 10-12s + // per pacchettizzare e registrare il WebAPK nella schermata Home. + // Calibriamo la progress bar spalmata su ~11 secondi reali prima del reindirizzamento. + this.isInstalling.set(true); + this.isRedirecting.set(false); + + if ((window as any).PwaLoader) { + (window as any).PwaLoader.show(); + (window as any).PwaLoader.update({ + title: 'Installazione applicazione', + phase: 'Fase: Registrazione', + desc: 'Generazione e registrazione dell\'applicazione sul dispositivo in corso...', + percent: 15 + }); + } + + const startTime = Date.now(); + const TARGET_DURATION_MS = 11000; // 11 secondi reali per completare l'installazione WebAPK + + const timer = setInterval(() => { + const elapsed = Date.now() - startTime; + let pct = Math.min(100, Math.round(15 + (elapsed / TARGET_DURATION_MS) * 85)); + + if ((window as any).PwaLoader) { + (window as any).PwaLoader.update({ percent: pct }); + } + + if (pct >= 100) { + clearInterval(timer); + this.isInstalling.set(false); + this.isRedirecting.set(true); + + if ((window as any).PwaLoader) { + (window as any).PwaLoader.update({ + title: 'Chiudi il browser', + desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.', + isRedirect: true + }); + } + + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + console.log('[AppComponent] Running on localhost - skipping protocol link redirect on appinstalled.'); + this.isRedirecting.set(false); + this.checkLoaderDismissal(); + return; + } + + setTimeout(() => { + window.location.href = this.protocolLink; + }, 1000); + } + }, 250); }); this.checkAndRedirectToPwa(); @@ -375,9 +415,8 @@ export class AppComponent implements OnInit { } async checkVersionSync(): Promise { - // Controlla SEMPRE version.json per primo — è il modo più affidabile per - // rilevare un disallineamento di versione, indipendentemente dallo stato del SW. - // Su mobile, checkForUpdate() può essere lento o inaffidabile. + // Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate) + // Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente. try { console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`); const response = await Promise.race([ @@ -387,10 +426,9 @@ export class AppComponent implements OnInit { if (response && response.ok) { const data = await response.json(); if (data && data.version && data.version !== VERSION) { - console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Forza aggiornamento...`); - const overlay = showFullscreenUpdateOverlay(); + console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Avvio aggiornamento automatico...`); - // Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW) + // Scarica e attiva il nuovo Service Worker se abilitato if (this.swUpdate.isEnabled) { try { const hasSwUpdate = await Promise.race([ @@ -398,7 +436,7 @@ export class AppComponent implements OnInit { new Promise((resolve) => setTimeout(() => resolve(false), 8000)) ]); if (hasSwUpdate) { - console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...'); + console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...'); await Promise.race([ this.swUpdate.activateUpdate(), new Promise((resolve) => setTimeout(resolve, 5000)) @@ -409,7 +447,7 @@ export class AppComponent implements OnInit { } } - // Aggiorna anche la registrazione SW direttamente (doppia sicurezza) + // Forziamo il controllo di aggiornamento della registrazione Service Worker if ('serviceWorker' in navigator) { try { const registration = await navigator.serviceWorker.ready; @@ -419,7 +457,7 @@ export class AppComponent implements OnInit { } } - // Disattiva service worker attivi per forzare il refresh completo + // Deregistra i vecchi Service Worker per applicare la nuova versione pulita if ('serviceWorker' in navigator) { const registrations = await navigator.serviceWorker.getRegistrations(); for (const registration of registrations) { @@ -427,7 +465,7 @@ export class AppComponent implements OnInit { } } - // Cancella le cache del browser + // Pulisci le cache del browser if ('caches' in window) { const keys = await caches.keys(); for (const key of keys) { @@ -435,9 +473,7 @@ export class AppComponent implements OnInit { } } - overlay.finish(); - - // Ricarica con parametro cache-busting per forzare l'allineamento remoto + // Ricarica la pagina in modo trasparente const url = new URL(window.location.href); url.searchParams.set('update_cb', Date.now().toString()); window.location.replace(url.toString()); @@ -450,13 +486,12 @@ export class AppComponent implements OnInit { 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 + // Fallback: Controlla direttamente tramite SwUpdate se non intercettato da version.json 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; @@ -467,7 +502,6 @@ export class AppComponent implements OnInit { 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) { @@ -481,43 +515,36 @@ export class AppComponent implements OnInit { } } - 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); + const url = new URL(window.location.href); + url.searchParams.set('update_cb', Date.now().toString()); + window.location.replace(url.toString()); }; - // 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'); + console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio, ricaricamento automatico...'); 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((resolve) => setTimeout(() => resolve(false), 8000)) ]); if (hasUpdate) { - console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...'); - overlay = showFullscreenUpdateOverlay(); + console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Applicazione automatica...'); - // 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); + }, 15000); - return true; // Attendi il ricaricamento + return true; } else { sub.unsubscribe(); } @@ -546,34 +573,23 @@ export class AppComponent implements OnInit { const path = window.location.pathname; this.protocolLink = `web+canti://open${path}${search}`; - // Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo - let isInstalled = localStorage.getItem('pwa-installed') === 'true'; - if (!isInstalled && 'getInstalledRelatedApps' in navigator) { + let isInstalled = false; + if ('getInstalledRelatedApps' in navigator) { try { const relatedApps = await (navigator as any).getInstalledRelatedApps(); isInstalled = relatedApps.length > 0; - if (isInstalled) { - localStorage.setItem('pwa-installed', 'true'); - } } catch (e) { console.warn('Failed to check installed apps:', e); } + } else { + isInstalled = localStorage.getItem('pwa-installed') === 'true'; } - // Se non è rilevata in localStorage/relatedApps ed è Android o Desktop con supporto ai prompt: - // attendiamo 1.5s per dare tempo all'evento 'beforeinstallprompt' di scattare. - // Se non scatta, significa che l'app è già installata. - if (!isInstalled && !this.settingsService.isIos() && ('onbeforeinstallprompt' in window)) { - await new Promise(resolve => setTimeout(resolve, 1500)); - 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'); - } - } + // Se prima era salvata come installata ma l'utente riceve il prima possibile un beforeinstallprompt, + // significa che l'app è stata disinstallata! + if (this.settingsService.deferredPrompt() || this.settingsService.showInstallButton()) { + isInstalled = false; + localStorage.setItem('pwa-installed', 'false'); } this.isPwaInstalled.set(isInstalled); diff --git a/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.html b/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.html new file mode 100644 index 0000000..24a457b --- /dev/null +++ b/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.html @@ -0,0 +1,41 @@ + + + Condividi Playlist + + + + + + + + + +
+

+ Fai scansionare questo QR Code da un altro dispositivo per condividere all'istante la playlist {{ playlistName }}. +

+ +
+
+ QR Code della Playlist +
+ +
+ Nome Playlist: + {{ playlistName }} +
+
+ +
+ + + Invia Link / Condividi + + + + + Copia Link + +
+
+
diff --git a/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.scss b/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.scss new file mode 100644 index 0000000..86e0960 --- /dev/null +++ b/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.scss @@ -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; +} diff --git a/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.ts b/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.ts new file mode 100644 index 0000000..2d75087 --- /dev/null +++ b/src/app/components/share-playlist-qr-modal/share-playlist-qr-modal.component.ts @@ -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(); + } +} diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index a091511..5f332cc 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -6,12 +6,15 @@
{{ appName }} - v{{ version }} - {{ settingsService.userName() }} + v{{ version }}
+ + + @@ -35,117 +38,328 @@ placeholder="Cerca un canto..." [value]="searchQuery()" (ionInput)="onSearch($event)" + [disabled]="isAdvancedSearchOpen()" class="custom-searchbar"> - - + + + + + - -
-
- {{ filteredCanti().length }} -
-
- - {{ totalPlaylistDuration() }} -
-
- - {{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }} - - - - - - - - -
- - - {{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }} - + +
+
+
+ {{ filteredCanti().length }} +
+ + + +
+
+ + +
+
+
+ +
+ + + + + + +
- - {{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }} - - - - - - - - {{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }} - - - - - - - - {{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }} - - - - - - - - Suggeriti - - - - +
+ +
+ + + + + + +
+
- - Top Ten - - +
+ +
+ + + + + + +
+
+ +
+ +
+ + + + + + +
+
+
+
+ + +
+
+ Playlist +
+ +
+ + + {{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }} + +
+ + +
+ + {{ item.name }} + + + + + + +
+ + + Nessuna playlist creata - +
+
+ + +
+ + +
+ + +
+ Tipologia +
+
+ Validati + + + +
+
+ Non Validati + + + + + +
+
+
+ + +
+ Liturgia +
+
+ {{ item.tag_name }} + + + +
+
+
+ + +
+ Periodo +
+
+ {{ item.tag_name }} + + + +
+
+
+ + +
+ + +
+ +
+ + {{ totalPlaylistDuration() }} +
+ + +
+ {{ playlistService.activeListName() }} +
+ + +
+ + + + + + + + + + + + + + + + + + +
- - -
{{ reorderList().length }} @@ -166,68 +380,6 @@
- - -
- - - - - - - - - - - - - - - -
- - - -
- Validati -
-
- Non Validati - - -
-
- - -
- - {{ activeFilterType() === 'playlist' ? item.name : item.tag_name }} -
-
-
- - -
-

- Per creare una playlist, seleziona il nr del canto che vuoi inserire nella playlist, riordinali e salvala con nome -

@@ -244,6 +396,7 @@
{{ cantiService.progress() }}%
+

Caricamento canti...

@@ -473,7 +626,6 @@
-

Schermo nero attivo

diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 346258f..4a41ee1 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -48,102 +48,447 @@ background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); - .voice-search-btn { - --padding-start: 8px; - --padding-end: 8px; + .adv-search-btn, .voice-search-btn { + --padding-start: 6px; + --padding-end: 6px; margin: 0; height: 44px; ion-icon { - font-size: 1.5rem; + font-size: 1.4rem; } } + + .adv-search-btn.active { + opacity: 1; + } } -.filter-actions-row { +.advanced-search-toggle-row { display: flex; align-items: center; - width: 100%; - overflow-x: auto; - gap: 12px; - padding: 8px 4px 8px 0; - - // Hide scrollbar but keep functionality - &::-webkit-scrollbar { - display: none; + justify-content: space-between; + padding: 0 2px; + gap: 4px; + flex-wrap: nowrap; + + .toggle-row-left { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + width: 100%; + justify-content: flex-start; } - -ms-overflow-style: none; - scrollbar-width: none; .song-count-card { - display: flex; + display: inline-flex; align-items: center; justify-content: center; background: rgba(var(--ion-color-secondary-rgb), 0.15); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3); - padding: 0 12px; + padding: 0 8px; border-radius: 12px; - height: 32px; - font-size: 0.85rem; - font-weight: 800; + height: 30px; + font-size: 0.8rem; + font-weight: 700; color: var(--ion-color-secondary); backdrop-filter: blur(10px); flex-shrink: 0; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + } - .playlist-total-duration { - font-weight: 400; - opacity: 0.75; - font-size: 0.8rem; - letter-spacing: 0.01em; + .advanced-search-link, .clear-advanced-link { + background: transparent; + border: none; + outline: none; + color: var(--ion-color-secondary); + font-size: 0.8rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 3px; + cursor: pointer; + padding: 4px 5px; + border-radius: 8px; + transition: all 0.2s ease; + opacity: 0.9; + white-space: nowrap; + flex-shrink: 0; + + &:hover, &:active { + opacity: 1; + background: rgba(var(--ion-color-secondary-rgb), 0.12); + } + + ion-icon { + font-size: 1rem; + } + + .inline-clear-btn { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 2px; + padding: 2px; + border-radius: 50%; + transition: transform 0.15s ease; + + ion-icon { + font-size: 1.1rem; + color: var(--ion-color-danger, #ff4961); + } + + &:hover { + transform: scale(1.2); + } } } - .filter-buttons { + .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; - gap: 8px; - flex-shrink: 0; + flex-direction: column; + gap: 4px; - 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; + .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; + } - .close-icon-wrapper { - display: inline-flex; - align-items: center; - justify-content: center; - margin-left: 6px; - padding: 4px; - margin-right: -8px; - cursor: pointer; - z-index: 100; - - ion-icon { - font-size: 1.2rem; - margin: 0; - pointer-events: none; + .adv-input-wrapper { + position: relative; + display: flex; + align-items: center; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 0 10px; + height: 38px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus-within { + border-color: var(--ion-color-secondary); + box-shadow: 0 0 0 2px rgba(var(--ion-color-secondary-rgb), 0.2); + } + + .adv-input-icon { + font-size: 1.1rem; + color: var(--ion-color-secondary); + margin-right: 8px; + flex-shrink: 0; + } + + .adv-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: #ffffff; + font-size: 0.88rem; + font-family: inherit; + width: 100%; + + &::placeholder { + color: rgba(255, 255, 255, 0.35); } + } - &:active { - opacity: 0.5; - transform: scale(0.9); + .adv-clear-icon { + font-size: 1rem; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + margin-left: 4px; + + &:hover { + color: #ffffff; + } + } + + .adv-mic-btn { + --padding-start: 4px; + --padding-end: 4px; + margin: 0 0 0 2px; + height: 32px; + min-height: 32px; + + ion-icon { + font-size: 1.15rem; } } } + } +} +@keyframes advSearchFadeIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +:host-context(body.high-contrast) { + .advanced-search-link { + color: var(--ion-color-secondary) !important; + } + + .clear-advanced-link { + color: var(--ion-color-danger, #d32f2f) !important; + } + + .advanced-search-panel { + background: #ffffff !important; + border: 1px solid rgba(0, 0, 0, 0.15) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important; + + .adv-input-group { + .adv-label { + color: var(--ion-color-secondary, #d96b00) !important; + font-weight: 700 !important; + } + + .adv-input-wrapper { + background: #f4f5f8 !important; + border: 1px solid #c0c4cc !important; + + &:focus-within { + border-color: var(--ion-color-secondary, #d96b00) !important; + box-shadow: 0 0 0 2px rgba(217, 107, 0, 0.25) !important; + } + + .adv-input-icon { + color: var(--ion-color-secondary, #d96b00) !important; + } + + .adv-input { + color: #000000 !important; + font-weight: 600 !important; + + &::placeholder { + color: #666666 !important; + font-weight: 400 !important; + } + } + + .adv-clear-icon { + color: #666666 !important; + + &:hover { + color: #000000 !important; + } + } + } + } + } +} + +.filter-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + backdrop-filter: blur(12px); + margin-top: 4px; + + .filter-card-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 34px; + + .filter-row-label { + font-weight: 700; + font-size: 0.8rem; + color: var(--ion-color-secondary); + width: 68px; + flex-shrink: 0; + text-transform: capitalize; + letter-spacing: 0.3px; + opacity: 0.9; + } + + .filter-row-items { + display: flex; + gap: 6px; + overflow-x: auto; + flex: 1; + align-items: center; + padding-bottom: 2px; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + + .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 { background: transparent !important; padding-bottom: 120px; // Spazio per il player fisso @@ -237,16 +582,17 @@ ion-title { display: flex; align-items: center; justify-content: flex-start; - gap: 12px; - padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room + gap: 8px; + padding: 10px 0 10px 8px; } .header-logo { - width: 42px; - height: 42px; + width: 38px; + height: 38px; border-radius: 50%; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2); + flex-shrink: 0; } .header-text-group { @@ -257,17 +603,19 @@ ion-title { line-height: 1; .app-name { - font-size: 1.4rem; + font-size: clamp(1.1rem, 3.8vw, 1.25rem); font-weight: 700; color: var(--ion-color-secondary); margin-bottom: 2px; + white-space: nowrap; } .version-badge { - font-size: 0.8rem; + font-size: 0.75rem; font-weight: 500; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; + white-space: nowrap; } } } diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 0fabee1..a008e89 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -51,6 +51,18 @@ export class HomePage implements OnDestroy { } public searchQuery = signal(''); + public isAdvancedSearchOpen = signal(false); + public isFilterCardOpen = signal(false); + public isPlaylistCardOpen = signal(false); + public searchTitle = signal(''); + public searchAuthor = signal(''); + public searchText = signal(''); + public searchNumber = signal(''); + 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(null); public selectedTematico = signal(null); public showOnlyMine = signal(false); @@ -58,6 +70,25 @@ export class HomePage implements OnDestroy { public showSuggeriti = signal(false); public showValidati = signal(false); public showNonValidati = signal(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(false); public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null); public loadedThumbs = new Set(); @@ -205,6 +236,24 @@ export class HomePage implements OnDestroy { } catch (e) { console.warn('[PWA-Update] activateUpdate failed:', e); } + + try { + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.unregister(); + } + } + if ('caches' in window) { + const keys = await caches.keys(); + for (const key of keys) { + await caches.delete(key); + } + } + } catch (e) { + console.warn('[PWA-Update] Cleanup failed:', e); + } + overlay.finish(); setTimeout(() => { window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now()); @@ -239,17 +288,12 @@ export class HomePage implements OnDestroy { private async checkUpdateStatus() { try { + let swFoundUpdate = false; if (this.swUpdate.isEnabled) { - const swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false); - if (swFoundUpdate) { - this.hasUpdateAvailable.set(true); - return; - } + swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false); } const mismatch = await this.checkVersionJson(); - if (mismatch) { - this.hasUpdateAvailable.set(true); - } + this.hasUpdateAvailable.set(swFoundUpdate || mismatch); } catch (err) { console.warn('Failed to check for updates on startup:', err); } @@ -415,36 +459,85 @@ export class HomePage implements OnDestroy { return commNum ? commNum === targetId : c.id_canti.toString() === targetId; }); } else { + const tokens = query.split(/\s+/).filter(t => t.length > 0); + list = list.filter(c => { - const titleMatch = this.normalize(c.titolo).includes(query); - const authorMatch = this.normalize(c.autore).includes(query); + const normTitle = this.normalize(c.titolo); + const normAuthor = this.normalize(c.autore); const commNum = this.getCommunitySongNumber(c); - const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query); + const songNum = commNum || c.id_canti.toString(); const lyricsPlain = (c.testo || '') .replace(/{.*?}/g, '') .replace(/\n/g, ' '); - const lyricsMatch = this.normalize(lyricsPlain).includes(query); + const normLyrics = this.normalize(lyricsPlain); - return titleMatch || authorMatch || lyricsMatch || numberMatch; + // Full string matches in title, author, lyrics, or song number + const titleMatch = normTitle.includes(query); + const authorMatch = normAuthor.includes(query); + const lyricsMatch = normLyrics.includes(query); + const numberMatch = songNum.includes(query); + + if (titleMatch || authorMatch || lyricsMatch || numberMatch) { + return true; + } + + // Multi-word token match across fields (e.g. title word + author word) + if (tokens.length > 1) { + return tokens.every(token => + normTitle.includes(token) || + normAuthor.includes(token) || + normLyrics.includes(token) || + songNum.includes(token) + ); + } + + return false; }); } } + // Advanced search filters (titolo, autore, testo, nr canto) + const advTitle = this.normalize(this.searchTitle()).trim(); + const advAuthor = this.normalize(this.searchAuthor()).trim(); + const advText = this.normalize(this.searchText()).trim(); + const advNumber = this.normalize(this.searchNumber()).trim(); + + if (advTitle) { + list = list.filter(c => this.normalize(c.titolo).includes(advTitle)); + } + + if (advAuthor) { + list = list.filter(c => this.normalize(c.autore || '').includes(advAuthor)); + } + + if (advText) { + list = list.filter(c => { + const lyricsPlain = (c.testo || '') + .replace(/{.*?}/g, '') + .replace(/\n/g, ' '); + return this.normalize(lyricsPlain).includes(advText); + }); + } + + if (advNumber) { + list = list.filter(c => { + const commNum = this.getCommunitySongNumber(c); + const songNum = (commNum ? commNum.toString() : c.id_canti.toString()).toLowerCase().trim(); + return songNum === advNumber.toLowerCase().trim(); + }); + } + // G. Sorting/Ordering if (topTen) { - const eseguiti = this.cantiService.cantiEseguiti(); - const eseguitiMap = new Map(); - eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num)); - - list = list.sort((a, b) => { - const numA = eseguitiMap.get(a.id_canti) || 0; - const numB = eseguitiMap.get(b.id_canti) || 0; + list = [...list].sort((a, b) => { + const numA = this.getEsecuzioniCount(a.id) || 0; + const numB = this.getEsecuzioniCount(b.id) || 0; return numB - numA; }); } else if (suggeriti) { const suggMap = this.cantiLettureService.suggestionsMap(); - list = list.sort((a, b) => { + list = [...list].sort((a, b) => { const pesoA = suggMap.get(a.id_canti) || 0; const pesoB = suggMap.get(b.id_canti) || 0; return pesoB - pesoA; @@ -464,8 +557,24 @@ export class HomePage implements OnDestroy { 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.activeFilterType() !== 'playlist' || this.playlistService.activeListName() === null) return ''; + if (this.playlistService.activePlaylistId() === null) return ''; const songs = this.filteredCanti(); if (songs.length === 0) return ''; @@ -523,7 +632,11 @@ export class HomePage implements OnDestroy { this.playlistService.checkForRemotePlaylistUpdates(); } }, 30000); - + // Sync homepage filtered canti IDs to playlistService.filteredListIds + effect(() => { + const ids = this.filteredCanti().map(c => c.id); + this.playlistService.filteredListIds.set(ids); + }, { allowSignalWrites: true }); // Track initial community filter state to avoid clearing during the initial run of the effect @@ -555,11 +668,29 @@ export class HomePage implements OnDestroy { } }, { allowSignalWrites: true }); - // Sync speech recognition results to search query + // Sync speech recognition results to search query or advanced field effect(() => { const transcript = this.audioEngine.searchTranscript(); if (transcript) { - 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; + } } }); @@ -943,6 +1074,7 @@ export class HomePage implements OnDestroy { // Select it immediately this.selectPlaylist(selectedPl); + this.playlistService.lastPlaylist.set(selectedPl); this.activeFilterType.set('playlist'); await loading.dismiss(); @@ -1097,6 +1229,95 @@ export class HomePage implements OnDestroy { this.limit.set(30); } + toggleAdvancedSearch() { + const willOpen = !this.isAdvancedSearchOpen(); + this.isAdvancedSearchOpen.set(willOpen); + if (willOpen) { + this.isFilterCardOpen.set(false); + this.isPlaylistCardOpen.set(false); + } + } + + toggleFilterCard() { + const willOpen = !this.isFilterCardOpen(); + this.isFilterCardOpen.set(willOpen); + if (willOpen) { + this.isAdvancedSearchOpen.set(false); + this.isPlaylistCardOpen.set(false); + } + } + + togglePlaylistCard() { + const willOpen = !this.isPlaylistCardOpen(); + this.isPlaylistCardOpen.set(willOpen); + if (willOpen) { + this.isAdvancedSearchOpen.set(false); + this.isFilterCardOpen.set(false); + } + } + + clearAdvancedSearch(event?: Event) { + if (event) { + event.stopPropagation(); + } + this.searchTitle.set(''); + this.searchAuthor.set(''); + this.searchText.set(''); + this.searchNumber.set(''); + this.limit.set(30); + } + + clearFilterCard(event?: Event) { + if (event) { + event.stopPropagation(); + } + this.selectedLiturgico.set(null); + this.selectedTematico.set(null); + this.showOnlyMine.set(false); + this.showTopTen.set(false); + this.showSuggeriti.set(false); + this.showValidati.set(false); + this.showNonValidati.set(false); + this.limit.set(30); + } + + clearPlaylistCard(event?: Event) { + if (event) { + event.stopPropagation(); + } + this.playlistService.activeListIds.set([]); + this.playlistService.activeListName.set(null); + this.playlistService.activePlaylistId.set(null); + if (this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive()) { + this.toggleComunitaFilter(); + } + this.limit.set(30); + } + + onSearchTitleInput(event: any) { + const val = event.target?.value ?? event.detail?.value ?? ''; + this.searchTitle.set(val); + this.limit.set(30); + } + + onSearchAuthorInput(event: any) { + const val = event.target?.value ?? event.detail?.value ?? ''; + this.searchAuthor.set(val); + this.limit.set(30); + } + + onSearchTextInput(event: any) { + const val = event.target?.value ?? event.detail?.value ?? ''; + this.searchText.set(val); + this.limit.set(30); + } + + onSearchNumberInput(event: any) { + const val = event.target?.value ?? event.detail?.value ?? ''; + this.searchNumber.set(val); + this.limit.set(30); + } + async deleteMyCanto(id: string, event: Event) { event.stopPropagation(); const alert = await this.alertCtrl.create({ @@ -1256,7 +1477,8 @@ export class HomePage implements OnDestroy { this.showValidati() || this.showNonValidati() || this.playlistService.activeListName() !== null || - this.searchQuery() !== ''; + this.searchQuery() !== '' || + this.hasAdvancedSearchParams(); } shouldShowSubSectionToolbar(): boolean { @@ -1282,6 +1504,7 @@ export class HomePage implements OnDestroy { this.playlistService.activeListName.set(null); this.playlistService.activePlaylistId.set(null); this.searchQuery.set(''); + this.clearAdvancedSearch(); this.activeFilterType.set(null); this.limit.set(10); } @@ -1315,6 +1538,8 @@ export class HomePage implements OnDestroy { if (pl.isRemote || pl.id?.startsWith('remote_')) { this.playlistService.checkForRemotePlaylistUpdates(); } + this.isAdvancedSearchOpen.set(false); + this.isFilterCardOpen.set(false); // Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva this.limit.set(50); } @@ -1720,10 +1945,16 @@ export class HomePage implements OnDestroy { } } - toggleVoiceSearch() { + toggleVoiceSearch(target: 'global' | 'title' | 'author' | 'text' | 'number' = 'global') { if (this.audioEngine.isSearching()) { + const currentTarget = this.activeVoiceField(); this.audioEngine.stopSearchRecognition(); + if (currentTarget !== target) { + this.activeVoiceField.set(target); + this.audioEngine.startSearchRecognition(); + } } else { + this.activeVoiceField.set(target); this.audioEngine.startSearchRecognition(); } } @@ -1794,12 +2025,24 @@ export class HomePage implements OnDestroy { isComunitaPlaylist(): boolean { const id = this.playlistService.activePlaylistId(); - return !!id && id.startsWith('comunita_'); + if (id && id.startsWith('comunita_')) return true; + const name = this.playlistService.activeListName(); + if (name) { + const pl = this.playlistService.allPlaylists().find(p => p.name === name); + return !!pl?.isComunita; + } + return false; } isRemotePlaylist(): boolean { const id = this.playlistService.activePlaylistId(); - return !!id && id.startsWith('remote_'); + if (id && id.startsWith('remote_')) return true; + const name = this.playlistService.activeListName(); + if (name) { + const pl = this.playlistService.allPlaylists().find(p => p.name === name); + return !!pl?.isRemote; + } + return false; } async refreshActiveRemotePlaylist() { @@ -1829,8 +2072,10 @@ export class HomePage implements OnDestroy { isActivePlaylistSaved(): boolean { const id = this.playlistService.activePlaylistId(); - if (!id) return false; - return this.playlistService.playlists().some(p => p.id === id); + if (id) return this.playlistService.playlists().some(p => p.id === id); + const name = this.playlistService.activeListName(); + if (name) return this.playlistService.playlists().some(p => p.name === name); + return false; } clearSpecialList(event?: Event) { @@ -1860,6 +2105,59 @@ export class HomePage implements OnDestroy { this.playlistService.sharePlaylistQR(ids, name, songSettings); } + async printActivePlaylist() { + const ids = this.playlistService.activeListIds(); + if (ids.length === 0) return; + + const allCanti = this.cantiService.canti(); + const myCantiList = this.myCantiService.myCanti(); + const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali(); + const communityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive(); + + const songs = ids.map(id => { + let found: any = null; + if (communityActive) { + found = comunitaCantiPers.find(c => c.id === id); + } + if (!found) { + found = allCanti.find(c => c.id === id); + } + if (!found) { + found = myCantiList.find(c => c.id === id); + } + if (!found) { + found = comunitaCantiPers.find(c => c.id === id); + } + return found; + }).filter(c => !!c); + + const alert = await this.alertCtrl.create({ + header: 'Esporta in PDF', + message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?', + buttons: [ + { + text: 'Solo Testo', + handler: () => { + const name = this.playlistService.activeListName() || 'Playlist'; + this.playlistService.printPlaylist(name, songs, false); + } + }, + { + text: 'Testo e Accordi', + handler: () => { + const name = this.playlistService.activeListName() || 'Playlist'; + this.playlistService.printPlaylist(name, songs, true); + } + }, + { + text: 'Annulla', + role: 'cancel' + } + ] + }); + await alert.present(); + } + async cloneActivePlaylist() { const id = this.playlistService.activePlaylistId(); const currentName = this.playlistService.activeListName() || 'Playlist'; @@ -2018,7 +2316,40 @@ export class HomePage implements OnDestroy { getSongTonality(canto: any): string | null { if (!canto) return null; - return this.lyricsParser.deduceTonality(canto.accordi || canto.testo || ''); + 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() { @@ -2062,6 +2393,36 @@ export class HomePage implements OnDestroy { } } + private panelStartX: number = 0; + private panelStartY: number = 0; + + onPanelTouchStart(event: TouchEvent) { + if (event.touches.length === 1) { + this.panelStartX = event.touches[0].clientX; + this.panelStartY = event.touches[0].clientY; + } + } + + onPanelTouchEnd(event: TouchEvent, panelType: 'advancedSearch' | 'playlistCard' | 'filterCard') { + if (event.changedTouches.length === 1) { + const endX = event.changedTouches[0].clientX; + const endY = event.changedTouches[0].clientY; + const diffX = endX - this.panelStartX; + const diffY = endY - this.panelStartY; + + // Swipe UP: vertical movement upwards (diffY negative, e.g. < -40) and larger than horizontal diff + if (diffY < -40 && Math.abs(diffY) > Math.abs(diffX)) { + if (panelType === 'advancedSearch') { + this.isAdvancedSearchOpen.set(false); + } else if (panelType === 'playlistCard') { + this.isPlaylistCardOpen.set(false); + } else if (panelType === 'filterCard') { + this.isFilterCardOpen.set(false); + } + } + } + } + async navigateMassDate(direction: number) { const list = this.cantiLettureService.availableMasses(); if (list.length === 0) return; diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index 8212ee8..a96c718 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -4,36 +4,58 @@
- + {{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }} - + {{ getCommunitySongNumber(canto()) }} {{ canto()?.titolo || 'Player' }}
-
- +
+ {{ canto()?.autore || 'Autore sconosciuto' }}
-
+
- - + + {{ canto()?.durata }} - - + + {{ canto()?.bpm }} BPM - - + + {{ tonality() }} @@ -234,6 +256,9 @@ [color]="showChords() ? 'secondary' : 'medium'"> + + +
@@ -256,7 +281,8 @@
- {{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }} + {{ tonality() }} + ({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})
@@ -293,7 +319,6 @@
-

Schermo nero attivo

diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index c844091..2ffaf15 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -191,7 +191,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public faceDetector = inject(FaceDetectorService); private meta = inject(Meta); - public enableCameraNavigation = signal(false); + public enableCameraNavigation = computed(() => this.settingsService.cameraNavigationActive()); private songStartTime: number = 0; @@ -520,6 +520,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { ngAfterViewInit() { this.checkLandscapeZoom(); this.checkTitleWrap(); + if (this.settingsService.cameraNavigationActive()) { + this.initCameraTracking(); + } const gestureX = this.gestureCtrl.create({ el: this.el.nativeElement, direction: 'x', @@ -977,11 +980,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } - nextSong() { - let list = this.playlistService.activeListIds(); - if (list.length === 0) { - list = this.cantiService.canti().map(c => c.id); + private getSequenceList(): string[] { + const currentId = this.canto()?.id; + + // 1. Homepage filtered list (includes current sorting like Top Ten, Suggeriti, and any active playlist + additional filters) + const filtered = this.playlistService.filteredListIds(); + if (filtered.length > 0) { + if (!currentId || filtered.includes(currentId)) { + return filtered; + } } + + // 2. Active playlist raw list if present and contains current song + const active = this.playlistService.activeListIds(); + if (active.length > 0) { + if (!currentId || active.includes(currentId)) { + return active; + } + } + + // 3. Default fallback: all canti + return this.cantiService.canti().map(c => c.id); + } + + nextSong() { + const list = this.getSequenceList(); const currentId = this.canto()?.id; if (!currentId) return; @@ -999,10 +1022,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } prevSong() { - let list = this.playlistService.activeListIds(); - if (list.length === 0) { - list = this.cantiService.canti().map(c => c.id); - } + const list = this.getSequenceList(); const currentId = this.canto()?.id; if (!currentId) return; @@ -1193,6 +1213,35 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + async printCanto() { + const c = this.canto(); + if (!c) return; + + const alert = await this.alertCtrl.create({ + header: 'Esporta in PDF', + message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?', + buttons: [ + { + text: 'Solo Testo', + handler: () => { + this.playlistService.printPlaylist(c.titolo, [c], false, { [c.id || c.id_canti]: this.transposeAmount() }); + } + }, + { + text: 'Testo e Accordi', + handler: () => { + this.playlistService.printPlaylist(c.titolo, [c], true, { [c.id || c.id_canti]: this.transposeAmount() }); + } + }, + { + text: 'Annulla', + role: 'cancel' + } + ] + }); + await alert.present(); + } + private initPlayer(id: string) { if (!this.youtubePlayerService.isPlayerSupported()) { return; @@ -1299,7 +1348,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } toggleCameraNavigation() { - if (this.enableCameraNavigation()) { + if (this.settingsService.cameraNavigationActive()) { this.stopCameraNavigation(); } else { this.startCameraNavigation(); @@ -1307,8 +1356,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } startCameraNavigation() { - this.enableCameraNavigation.set(true); - + this.settingsService.cameraNavigationActive.set(true); + this.initCameraTracking(); + } + + initCameraTracking() { + if (!this.settingsService.cameraNavigationActive()) return; + setTimeout(async () => { const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement; if (videoEl) { @@ -1321,16 +1375,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.prev(true); } }); - } catch (e) { - this.enableCameraNavigation.set(false); - alert('Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.'); + } catch (e: any) { + this.settingsService.cameraNavigationActive.set(false); + const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto'; + alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`); } } }, 300); } stopCameraNavigation() { - this.enableCameraNavigation.set(false); + this.settingsService.cameraNavigationActive.set(false); this.faceDetector.stop(); } @@ -1338,7 +1393,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.exitFullscreen(); this.stopAutoscroll(); this.logPreviousSongTime(); - this.stopCameraNavigation(); + if (this.settingsService.cameraNavigationActive()) { + this.faceDetector.pause(); + } else { + this.faceDetector.stop(); + } this.channel.close(); } diff --git a/src/app/pages/playlist/playlist.page.html b/src/app/pages/playlist/playlist.page.html index 6162f4f..94a92fa 100644 --- a/src/app/pages/playlist/playlist.page.html +++ b/src/app/pages/playlist/playlist.page.html @@ -5,6 +5,9 @@ Gestione Playlist + + + diff --git a/src/app/pages/playlist/playlist.page.ts b/src/app/pages/playlist/playlist.page.ts index 068cefb..8b6053a 100644 --- a/src/app/pages/playlist/playlist.page.ts +++ b/src/app/pages/playlist/playlist.page.ts @@ -272,4 +272,42 @@ export class PlaylistPage { const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id); return index !== -1 ? index + 1 : 0; } + + async exportPlaylistPdf() { + if (this.localSongs.length === 0) return; + + const songSettings = this.getPlaylistSongSettings() || {}; + const transpositions: { [songId: string]: number } = {}; + for (const key of Object.keys(songSettings)) { + if (songSettings[key] && songSettings[key].tonalita !== undefined) { + transpositions[key] = songSettings[key].tonalita; + } + } + + const alert = await this.alertCtrl.create({ + header: 'Esporta in PDF', + message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?', + buttons: [ + { + text: 'Solo Testo', + handler: () => { + const name = this.savedPlaylistName || 'Playlist'; + this.playlistService.printPlaylist(name, this.localSongs, false, transpositions); + } + }, + { + text: 'Testo e Accordi', + handler: () => { + const name = this.savedPlaylistName || 'Playlist'; + this.playlistService.printPlaylist(name, this.localSongs, true, transpositions); + } + }, + { + text: 'Annulla', + role: 'cancel' + } + ] + }); + await alert.present(); + } } diff --git a/src/app/pages/propose-canto/propose-canto.page.html b/src/app/pages/propose-canto/propose-canto.page.html index a8b3b50..1f173b9 100644 --- a/src/app/pages/propose-canto/propose-canto.page.html +++ b/src/app/pages/propose-canto/propose-canto.page.html @@ -85,9 +85,14 @@
- - {{ tag.label }} - + + + {{ tag.label }} + + + Fine {{ tag.label }} + +
@@ -153,6 +158,9 @@ + + RAW +
diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index 0c7a163..523a24d 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -38,6 +38,7 @@ export class ProposeCantoPage implements OnInit, OnDestroy { showChordsPreview: boolean = true; activeTab: string = 'editor'; transposeAmount: number = 0; + isRawPasteModeActive: boolean = false; get highlightedHtml(): string { if (!this.content) return ''; @@ -223,31 +224,31 @@ export class ProposeCantoPage implements OnInit, OnDestroy { groupedChords = [ { 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', - chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7'] + chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'RE4'] }, { root: 'MI', - chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim'] + chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MI4'] }, { root: 'FA', - chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7'] + chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FA-7', 'FA4'] }, { root: 'SOL', - chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7'] + chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOL-7', 'SOL4'] }, { 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', - chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim'] + chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SI4'] } ]; @@ -333,6 +334,19 @@ export class ProposeCantoPage implements OnInit, OnDestroy { 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() { if (this.undoStack.length > 0) { const previous = this.undoStack.pop(); @@ -593,6 +607,10 @@ export class ProposeCantoPage implements OnInit, OnDestroy { } 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; if (!items) return; diff --git a/src/app/services/canti-letture.service.ts b/src/app/services/canti-letture.service.ts index e039439..a13311e 100644 --- a/src/app/services/canti-letture.service.ts +++ b/src/app/services/canti-letture.service.ts @@ -1,7 +1,8 @@ import { Injectable, signal, inject, effect } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Storage } from '@ionic/storage-angular'; import { firstValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; export interface Suggestion { id_canto?: number; @@ -127,7 +128,18 @@ export class CantiLettureService { const savedDate = localStorage.getItem('selected-mass-date'); 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 @@ -137,24 +149,45 @@ export class CantiLettureService { async fetchData() { try { let fetchedData: CantiLettureData | null = null; - + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const todayStr = `${year}-${month}-${day}`; + const isProduction = window.location.hostname.includes('canticristiani.it'); const primaryUrl = isProduction ? `${window.location.origin}${this.SECURE_JSON_URL}` : 'https://www.canticristiani.it/api/cantiletture.json'; - const fallbackUrl = isProduction - ? 'https://www.canticristiani.it/api/cantiletture.json' - : this.JSON_URL; + // 1. Try static JSON endpoint first try { console.log('Fetching mass data from primary URL:', primaryUrl); - fetchedData = await firstValueFrom(this.http.get(`${primaryUrl}?t=${Date.now()}`)); + const res = await firstValueFrom(this.http.get(`${primaryUrl}?t=${Date.now()}`)); + if (res && res.masses && res.week_end && res.week_end >= todayStr) { + fetchedData = res; + } else { + console.warn('Primary JSON data is missing or out of date:', res?.week_end); + } } catch (err) { - console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err); + console.warn('Primary fetch failed:', err); + } + + // 2. Fallback to direct API endpoint using Basic Auth credentials + if (!fetchedData) { try { - fetchedData = await firstValueFrom(this.http.get(`${fallbackUrl}?t=${Date.now()}`)); - } catch (fallbackErr) { - console.error('Fallback fetch also failed:', fallbackErr); + console.log('Fetching mass data from direct API:', this.JSON_URL); + const authUser = environment.apiAuthUser || 'canti'; + const authPass = environment.apiAuthPass || 'antani2026'; + const headers = new HttpHeaders({ + 'Authorization': 'Basic ' + btoa(`${authUser}:${authPass}`) + }); + const res = await firstValueFrom(this.http.get(`${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); - // Always select today's mass if available on load, else find closest future date if (massesList.length > 0) { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const todayStr = `${year}-${month}-${day}`; - const match = massesList.find(m => m.date === todayStr); - if (match) { - this.selectedMassDate.set(match.date); - } else { - // If today is not present, find the first date that is in the future - const futureMatch = massesList.find(m => m.date >= todayStr); - if (futureMatch) { - this.selectedMassDate.set(futureMatch.date); + + const currentSelected = this.selectedMassDate(); + const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr); + + if (!isCurrentValid) { + const match = massesList.find(m => m.date === todayStr); + if (match) { + this.selectedMassDate.set(match.date); } else { - // Otherwise fall back to the last available mass date (closest to today) - this.selectedMassDate.set(massesList[massesList.length - 1].date); + // If today is not present, find the first date that is in the future + 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); + } } } } diff --git a/src/app/services/face-detector.service.ts b/src/app/services/face-detector.service.ts index 42e36a9..43b92a9 100644 --- a/src/app/services/face-detector.service.ts +++ b/src/app/services/face-detector.service.ts @@ -9,7 +9,7 @@ export class FaceDetectorService { public isTilted = signal(false); private stream: MediaStream | null = null; - private camera: any = null; + private animFrameId: number | null = null; private faceMesh: any = null; private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null; @@ -17,109 +17,98 @@ export class FaceDetectorService { private tiltStartTime: number = 0; private inCooldown: boolean = false; private lastTriggerTime: number = 0; - private readonly TILT_THRESHOLD = 15; // Degrees to trigger next/prev page + private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability) private readonly TILT_HOLD_MS = 300; // How long to hold the tilt - private readonly RETURN_THRESHOLD = 6; // Degrees to reset cooldown - private readonly TRIGGER_COOLDOWN_MS = 5000; // Minimum time between consecutive gestures in ms (5 seconds) + private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown + private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms constructor() {} /** - * Loads MediaPipe scripts dynamically if not already loaded. + * Loads MediaPipe script dynamically if not already loaded. */ private loadScripts(): Promise { return new Promise((resolve, reject) => { - if ((window as any).FaceMesh && (window as any).Camera) { + if ((window as any).FaceMesh) { resolve(); return; } - const cameraScript = document.createElement('script'); - cameraScript.src = 'assets/mediapipe/camera_utils.js'; - const faceMeshScript = document.createElement('script'); faceMeshScript.src = 'assets/mediapipe/face_mesh.js'; - - cameraScript.onload = () => { - document.head.appendChild(faceMeshScript); - }; - - faceMeshScript.onload = () => { - resolve(); - }; - - cameraScript.onerror = (err) => reject(err); + faceMeshScript.onload = () => resolve(); faceMeshScript.onerror = (err) => reject(err); - document.head.appendChild(cameraScript); + document.head.appendChild(faceMeshScript); }); } /** - * Starts camera capture and face mesh tracking. + * Starts or re-binds camera capture and face mesh tracking. + * If a stream is already active (e.g. navigating between songs on iPad), reuses it + * to avoid triggering repeated permission prompts on iOS/Safari. */ async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise { - if (this.isCameraActive()) return; this.onTiltCallback = onTilt; try { await this.loadScripts(); - // Request camera permissions and stream - this.stream = await navigator.mediaDevices.getUserMedia({ - video: { - width: { ideal: 320 }, - height: { ideal: 240 }, - facingMode: 'user' - }, - audio: false - }); + const isStreamActive = this.stream && + this.stream.active && + this.stream.getVideoTracks().some(track => track.readyState === 'live'); + + if (!isStreamActive) { + // Request camera permissions and stream ONCE + this.stream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 320 }, + height: { ideal: 240 }, + facingMode: 'user' + }, + audio: false + }); + } videoElement.srcObject = this.stream; videoElement.setAttribute('playsinline', 'true'); + (videoElement as any).playsInline = true; videoElement.muted = true; - videoElement.play(); - - const FaceMeshLib = (window as any).FaceMesh; - const CameraLib = (window as any).Camera; - - if (!FaceMeshLib || !CameraLib) { - throw new Error('MediaPipe libraries failed to initialize.'); + + try { + await videoElement.play(); + } catch (playErr) { + console.warn('[FaceDetector] Video play warning:', playErr); } - this.faceMesh = new FaceMeshLib({ - locateFile: (file: string) => `assets/mediapipe/${file}` - }); + if (!this.faceMesh) { + const FaceMeshLib = (window as any).FaceMesh; + if (!FaceMeshLib) { + throw new Error('MediaPipe FaceMesh library failed to initialize.'); + } - this.faceMesh.setOptions({ - maxNumFaces: 1, - refineLandmarks: false, - minDetectionConfidence: 0.6, - minTrackingConfidence: 0.6 - }); + this.faceMesh = new FaceMeshLib({ + locateFile: (file: string) => `assets/mediapipe/${file}` + }); - this.faceMesh.onResults((results: any) => { - this.processLandmarks(results); - }); + this.faceMesh.setOptions({ + maxNumFaces: 1, + refineLandmarks: false, + minDetectionConfidence: 0.6, + minTrackingConfidence: 0.6 + }); - let lastFrameTime = 0; - const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM - this.camera = new CameraLib(videoElement, { - onFrame: async () => { - if (this.isCameraActive() && this.faceMesh) { - const now = Date.now(); - if (now - lastFrameTime >= FRAME_INTERVAL_MS) { - lastFrameTime = now; - await this.faceMesh.send({ image: videoElement }); - } - } - }, - width: 320, - height: 240 - }); + this.faceMesh.onResults((results: any) => { + this.processLandmarks(results); + }); + } this.isCameraActive.set(true); - await this.camera.start(); + + // Start custom frame processing loop + this.stopFrameLoop(); + this.startFrameLoop(videoElement); + console.log('[FaceDetector] Face tracking started successfully.'); } catch (err) { console.error('[FaceDetector] Failed to start face tracking:', err); @@ -128,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. */ @@ -188,23 +209,24 @@ export class FaceDetectorService { } /** - * Stops camera capture and releases face mesh resources. + * Pauses the frame tracking loop without killing the underlying media stream hardware. + */ + pause() { + this.stopFrameLoop(); + this.isCameraActive.set(false); + } + + /** + * Stops camera capture and releases face mesh & hardware stream resources. */ stop() { - this.isCameraActive.set(false); + this.pause(); this.currentTiltAngle.set(0); this.isTilted.set(false); this.inCooldown = false; this.tiltStartTime = 0; this.lastTriggerTime = 0; - if (this.camera) { - try { - this.camera.stop(); - } catch (e) {} - this.camera = null; - } - if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); this.stream = null; @@ -218,6 +240,6 @@ export class FaceDetectorService { } this.onTiltCallback = null; - console.log('[FaceDetector] Face tracking stopped.'); + console.log('[FaceDetector] Face tracking fully stopped.'); } } diff --git a/src/app/services/lyrics-parser.service.ts b/src/app/services/lyrics-parser.service.ts index 0bcd630..e45043f 100644 --- a/src/app/services/lyrics-parser.service.ts +++ b/src/app/services/lyrics-parser.service.ts @@ -345,6 +345,11 @@ export class LyricsParserService { isContiguousNext(segments: ChordSegment[], index: number): boolean { if (!segments || index >= segments.length - 1) return false; + // Se sia il segmento corrente che il successivo hanno un accordo, non sono contigui (vogliamo dello spazio tra loro) + if (segments[index].chord && segments[index + 1].chord) { + return false; + } + // Find the next segment with non-empty text let nextWithText: ChordSegment | null = null; for (let i = index + 1; i < segments.length; i++) { diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts index bc943de..ed2b2dc 100644 --- a/src/app/services/playlist.service.ts +++ b/src/app/services/playlist.service.ts @@ -3,9 +3,11 @@ import { Storage } from '@ionic/storage-angular'; import { Canto, CantiService } from './canti.service'; import { ComunitaService } from './comunita.service'; import * as QRCode from 'qrcode'; -import { ToastController, AlertController } from '@ionic/angular'; +import { ToastController, AlertController, ModalController } from '@ionic/angular'; import { SettingsService } from './settings.service'; +import { SharePlaylistQrModalComponent } from '../components/share-playlist-qr-modal/share-playlist-qr-modal.component'; import { MyCantiService } from './my-canti.service'; +import { LyricsParserService } from './lyrics-parser.service'; @Injectable({ providedIn: 'root' @@ -13,9 +15,11 @@ import { MyCantiService } from './my-canti.service'; export class PlaylistService { private storage = inject(Storage); private cantiService = inject(CantiService); + private lyricsParser = inject(LyricsParserService); private comunitaService = inject(ComunitaService); private toastCtrl = inject(ToastController); private alertCtrl = inject(AlertController); + private modalCtrl = inject(ModalController); private settingsService = inject(SettingsService); private injector = inject(Injector); private myCantiService!: MyCantiService; @@ -29,6 +33,7 @@ export class PlaylistService { public activeListIds = signal([]); public activeListName = signal(null); public activePlaylistId = signal(null); + public filteredListIds = signal([]); public remotePlaylist = signal(null); public remoteCustomSongs = signal([]); @@ -665,108 +670,26 @@ export class PlaylistService { const activeId = this.activePlaylistId() || Date.now().toString(); const isRemote = activeId.startsWith('remote_'); - const buttons: any[] = [ - { - text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)', - handler: () => { - let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`; - if (isRemote) { - let remoteUid = uid; - let remotePid = activeId; - 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); - } + let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`; + if (isRemote) { + let remoteUid = uid; + let remotePid = activeId; + const parts = activeId.split('_'); + if (parts.length >= 3) { + remoteUid = parts[1]; + remotePid = parts[2]; } - ]; - - if (!isRemote) { - buttons.push({ - text: 'Modifica (Collaborazione / Backup)', - handler: () => { - const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`; - this.executeShare(shareLink, name + ' (Editor)'); - } - }); + shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`; } - buttons.push({ - text: 'Annulla', - role: 'cancel' - }); - - const alert = await this.alertCtrl.create({ - header: 'Condividi Playlist', - message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:', - buttons: buttons - }); - await alert.present(); - } - - private async executeShare(shareLink: string, name: string) { - // Generate QR using the link - const qrImage = await QRCode.toDataURL(shareLink, { - width: 400, - margin: 2, - color: { - dark: '#2d3436', - light: '#ffffff' + const modal = await this.modalCtrl.create({ + component: SharePlaylistQrModalComponent, + componentProps: { + playlistName: name, + shareLink: shareLink } }); - - 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); - } + await modal.present(); } async loadPlaylists() { @@ -792,4 +715,559 @@ export class PlaylistService { this.remoteShareCanti.set(updated); await this._storage?.set('remote_share_canti', updated); } + + public printPlaylist(playlistName: string, songs: any[], showChords: boolean, transpositions?: { [songId: string]: number }) { + const printWindow = window.open('', '_blank'); + if (!printWindow) { + alert('Impossibile aprire la finestra di stampa. Abilita i popup nel browser.'); + return; + } + + if (!this.myCantiService) { + this.myCantiService = this.injector.get(MyCantiService); + } + + const titleHtml = playlistName ? ` +
+
+

Playlist: ${playlistName}

+

Data: ${new Date().toLocaleDateString('it-IT')}  •  N. Canti: ${songs.length}

+
+
+ ` : ''; + + 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 = `${section.verseNumber}.`; + } + + 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 ? `${seg.chord}` : ''; + lineContentHtml += `${chordHtml}${seg.text || ''}`; + } + } else { + lineContentHtml = line.text; + } + linesHtml += `
${lineContentHtml}
`; + } + + songBodyHtml += `
${sectionHeader}${linesHtml}
`; + } + + contentHtml += ` +
+
+
+ ${displayNum} + ${song.titolo} +
+
+ ${song.autore ? `Autore: ${song.autore}` : ''} + ${song.durata ? `Durata: ${song.durata}` : ''} + ${song.bpm ? `BPM: ${song.bpm}` : ''} +
+
+
+ ${songBodyHtml} +
+
+ `; + } + + const html = ` + + + + + ${playlistName || 'Playlist'} + + + + + + + + ${titleHtml} + ${contentHtml} + + + + `; + + printWindow.document.open(); + printWindow.document.write(html); + printWindow.document.close(); + } } diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 1218c15..1d49ff4 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -50,6 +50,9 @@ export class SettingsService { /** Attiva autoscroll visuale nel dettaglio canto: true = attivo */ public enableVisualAutoscroll = signal(true); + /** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */ + public cameraNavigationActive = signal(false); + /** Preferenza notazione accordi: diesis o bemolle */ public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); @@ -137,6 +140,8 @@ export class SettingsService { this.deferredPrompt.set(e); // Update UI notify the user they can install the PWA this.showInstallButton.set(true); + // Se scatta l'evento di installazione, l'app NON è attualmente installata + localStorage.setItem('pwa-installed', 'false'); }); window.addEventListener('appinstalled', () => { @@ -355,6 +360,17 @@ export class SettingsService { localStorage.setItem('global-zoom', this.globalZoom().toString()); }); + const savedCameraNavActive = localStorage.getItem('camera-navigation-active'); + if (savedCameraNavActive !== null) { + this.cameraNavigationActive.set(savedCameraNavActive === 'true'); + } else { + this.cameraNavigationActive.set(false); + } + + effect(() => { + localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString()); + }); + effect(() => { const active = this.keepScreenOn(); localStorage.setItem('keep-screen-on', active.toString()); diff --git a/src/app/version.ts b/src/app/version.ts index faeb443..429d91c 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.07.14.1946'; +export const VERSION = '2026.08.08.1614'; diff --git a/src/index.html b/src/index.html index eff7823..1d10345 100644 --- a/src/index.html +++ b/src/index.html @@ -1,8 +1,9 @@ - + + CantiCristiani @@ -73,7 +74,11 @@ if (options.percent !== undefined) { const pct = Math.min(100, Math.max(0, Math.round(options.percent))); if (bar) bar.style.width = pct + '%'; - if (pctText) pctText.textContent = pct + '%'; + if (bar && bar.parentElement) bar.parentElement.style.display = 'block'; + if (pctText) { + pctText.style.display = 'block'; + pctText.textContent = pct + '%'; + } } if (options.isRedirect) { @@ -82,7 +87,7 @@ if (phaseEl) phaseEl.style.display = 'none'; if (versionEl) versionEl.style.display = 'none'; if (titleEl) { - titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device'; + titleEl.textContent = 'Chiudi il browser è stata installata la app sul device'; titleEl.style.fontSize = '1.4rem'; titleEl.style.lineHeight = '1.5'; }