fix(pwa): progress bar setup loading, PWA install detection, Miei filter chip & UI cleanup

This commit is contained in:
David Frassi
2026-08-08 16:17:20 +02:00
parent 0069c1a55f
commit a15a6c2139
21 changed files with 2378 additions and 559 deletions
+6
View File
@@ -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
+65 -49
View File
@@ -304,13 +304,51 @@ 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);
// Se la finestra è GIÀ stata trasformata in PWA standalone (es. Desktop Mac/Windows)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) {
this.isInstalling.set(false);
this.isRedirecting.set(false);
this.checkLoaderDismissal();
return;
}
// Su Android, l'evento 'appinstalled' scatta in 2-3 secondi, MA l'OS impiega 10-12s
// per pacchettizzare e registrare il WebAPK nella schermata Home.
// Calibriamo la progress bar spalmata su ~11 secondi reali prima del reindirizzamento.
this.isInstalling.set(true);
this.isRedirecting.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Installazione applicazione',
phase: 'Fase: Registrazione',
desc: 'Generazione e registrazione dell\'applicazione sul dispositivo in corso...',
percent: 15
});
}
const startTime = Date.now();
const TARGET_DURATION_MS = 11000; // 11 secondi reali per completare l'installazione WebAPK
const timer = setInterval(() => {
const elapsed = Date.now() - startTime;
let pct = Math.min(100, Math.round(15 + (elapsed / TARGET_DURATION_MS) * 85));
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: pct });
}
if (pct >= 100) {
clearInterval(timer);
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Chiudi il browser',
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
@@ -328,6 +366,8 @@ export class AppComponent implements OnInit {
setTimeout(() => {
window.location.href = this.protocolLink;
}, 1000);
}
}, 250);
});
this.checkAndRedirectToPwa();
@@ -375,9 +415,8 @@ export class AppComponent implements OnInit {
}
async checkVersionSync(): Promise<boolean> {
// Controlla SEMPRE version.json per primo — è il modo più affidabile per
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
// Su mobile, checkForUpdate() può essere lento o inaffidabile.
// Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
// Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
try {
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
const response = await Promise.race([
@@ -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<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]);
if (hasSwUpdate) {
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...');
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...');
await Promise.race([
this.swUpdate.activateUpdate(),
new Promise<void>((resolve) => setTimeout(resolve, 5000))
@@ -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);
};
// 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<boolean>((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);
@@ -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();
}
}
+272 -120
View File
@@ -6,12 +6,15 @@
<div class="header-text-group">
<span class="app-name">{{ appName }}</span>
<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>
</div>
</div>
</ion-title>
<ion-buttons slot="end">
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto">
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
</ion-button>
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
@@ -35,39 +38,134 @@
placeholder="Cerca un canto..."
[value]="searchQuery()"
(ionInput)="onSearch($event)"
[disabled]="isAdvancedSearchOpen()"
class="custom-searchbar">
</ion-searchbar>
<ion-button fill="clear" (click)="toggleVoiceSearch()" class="voice-search-btn">
<ion-icon slot="icon-only" [name]="audioEngine.isSearching() ? 'mic' : 'mic-outline'" [color]="audioEngine.isSearching() ? 'danger' : 'secondary'"></ion-icon>
<ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
<ion-icon slot="icon-only" name="options-outline" [color]="isAdvancedSearchOpen() ? 'secondary' : 'medium'"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleVoiceSearch('global')" class="voice-search-btn" [disabled]="isAdvancedSearchOpen()">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="filter-actions-row">
<!-- Advanced Search & Filters Toggle Row -->
<div class="advanced-search-toggle-row">
<div class="toggle-row-left">
<div class="song-count-card outfit-font">
{{ filteredCanti().length }}
</div>
<div *ngIf="playlistService.activeListName() !== null && totalPlaylistDuration()" class="song-count-card outfit-font">
<ion-icon name="time-outline" style="margin-right: 4px; font-size: 1rem; color: var(--ion-color-secondary);"></ion-icon>
<span>{{ totalPlaylistDuration() }}</span>
<button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
<ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
<span>Ricerche</span>
<span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
<ion-icon name="close-circle"></ion-icon>
</span>
</button>
<button type="button" class="advanced-search-link outfit-font" (click)="toggleFilterCard()">
<ion-icon [name]="isFilterCardOpen() ? 'chevron-up-outline' : 'filter-outline'"></ion-icon>
<span>Filtri</span>
<span class="inline-clear-btn" *ngIf="hasFilterCardParams()" (click)="clearFilterCard($event)" title="Reset filtri">
<ion-icon name="close-circle"></ion-icon>
</span>
</button>
<button type="button" class="advanced-search-link outfit-font" (click)="togglePlaylistCard()">
<ion-icon [name]="isPlaylistCardOpen() ? 'chevron-up-outline' : 'list-outline'"></ion-icon>
<span>Liste</span>
<span class="inline-clear-btn" *ngIf="hasPlaylistCardParams()" (click)="clearPlaylistCard($event)" title="Reset liste">
<ion-icon name="close-circle"></ion-icon>
</span>
</button>
</div>
</div>
<div class="filter-buttons">
<ion-button
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
size="small"
(click)="toggleFilterType('playlist')"
class="filter-chip">
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
<span *ngIf="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: 20px; height: 20px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3); vertical-align: middle;">
<ion-icon name="refresh-outline" style="font-size: 0.95rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<!-- Advanced Search Panel -->
<div class="advanced-search-panel glass outfit-font"
*ngIf="isAdvancedSearchOpen()"
(touchstart)="onPanelTouchStart($event)"
(touchend)="onPanelTouchEnd($event, 'advancedSearch')">
<div class="adv-search-grid">
<div class="adv-input-group">
<label class="adv-label">Ricerca per titolo</label>
<div class="adv-input-wrapper">
<ion-icon name="text-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Titolo canto..."
[value]="searchTitle()"
(input)="onSearchTitleInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchTitle()" (click)="searchTitle.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('title')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="adv-input-group">
<label class="adv-label">Ricerca per autore</label>
<div class="adv-input-wrapper">
<ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Nome autore..."
[value]="searchAuthor()"
(input)="onSearchAuthorInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchAuthor()" (click)="searchAuthor.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('author')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="adv-input-group">
<label class="adv-label">Ricerca per testo</label>
<div class="adv-input-wrapper">
<ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Parole nel testo..."
[value]="searchText()"
(input)="onSearchTextInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchText()" (click)="searchText.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('text')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
<div class="adv-input-group">
<label class="adv-label">Ricerca per nr canto</label>
<div class="adv-input-wrapper">
<ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
<input
type="text"
placeholder="Numero canto..."
[value]="searchNumber()"
(input)="onSearchNumberInput($event)"
class="adv-input" />
<ion-icon name="close-circle" *ngIf="searchNumber()" (click)="searchNumber.set('')" class="adv-clear-icon"></ion-icon>
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('number')" class="adv-mic-btn">
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'danger' : 'secondary'"></ion-icon>
</ion-button>
</div>
</div>
</div>
</div>
<!-- Playlist Card (default closed) -->
<div class="filter-card glass outfit-font"
*ngIf="isPlaylistCardOpen()"
(touchstart)="onPanelTouchStart($event)"
(touchend)="onPanelTouchEnd($event, 'playlistCard')">
<div class="filter-card-row">
<span class="filter-row-label">Playlist</span>
<div class="filter-row-items">
<!-- Comunità filter button if enabled -->
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<ion-button
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
size="small"
@@ -80,42 +178,114 @@
</ion-button>
</div>
<ion-button
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
size="small"
(click)="toggleFilterType('lista_completa')"
class="filter-chip">
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon>
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
<!-- Playlist Chips -->
<div
*ngFor="let item of playlistService.allPlaylists()"
class="momento-chip glass"
[class.active]="playlistService.activePlaylistId() === item.id"
[class.comunita-chip]="item.isComunita"
(click)="playlistService.activePlaylistId() === item.id ? clearSpecialList($event) : selectPlaylist(item)">
<ion-icon *ngIf="item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ item.name }}
<span *ngIf="playlistService.activePlaylistId() === item.id && isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 18px; height: 18px; vertical-align: middle;">
<ion-icon name="refresh-outline" style="font-size: 0.85rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="activeFilterType() === 'liturgico' || selectedLiturgico() !== null ? 'solid' : 'outline'"
size="small"
(click)="toggleFilterType('liturgico')"
class="filter-chip">
{{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
<span class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
<ion-icon name="close-circle"></ion-icon>
</span>
</ion-button>
</div>
<ion-button
[fill]="activeFilterType() === 'tematico' || selectedTematico() !== null ? 'solid' : 'outline'"
size="small"
(click)="toggleFilterType('tematico')"
class="filter-chip">
{{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
<span *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
Nessuna playlist creata
</span>
</ion-button>
</div>
</div>
<div class="filter-card-footer" *ngIf="hasPlaylistCardParams()">
<div></div>
<button type="button" class="clear-advanced-link outfit-font" (click)="clearPlaylistCard()">
<ion-icon name="close-circle-outline"></ion-icon>
<span>Reset liste</span>
</button>
</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"
@@ -137,15 +307,59 @@
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
<ion-button
[fill]="showOnlyMine() ? 'solid' : 'outline'"
size="small"
(click)="toggleOnlyMine()"
class="filter-chip">
Miei
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
<ion-icon slot="end" name="close-circle"></ion-icon>
</span>
</ion-button>
</div>
</div>
</div>
</div>
</ion-toolbar>
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
<div class="playlist-actions-bar">
<!-- Duration Badge on the left -->
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
<ion-icon name="time-outline"></ion-icon>
<span>{{ totalPlaylistDuration() }}</span>
</div>
<!-- Playlist Name in middle -->
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
</div>
<!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button>
</div>
<!-- Category Scroll (Dropdown style) -->
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="shouldShowSubSectionToolbar()">
<div class="momento-scroll" *ngIf="activeFilterType() !== 'playlist' || playlistService.allPlaylists().length > 0 || playlistService.selectionMode()">
<!-- Selection Mode Actions -->
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
<span class="selection-count">{{ reorderList().length }}</span>
@@ -166,68 +380,6 @@
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
</ion-button>
</div>
<!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="activeFilterType() === 'playlist' && playlistService.activePlaylistId() && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="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)="shareActivePlaylist()" class="mini-action-btn">
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button>
</div>
<!-- Filter chips -->
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
<div
class="momento-chip glass"
[class.active]="showValidati()"
(click)="toggleValidati()">
Validati
</div>
<div
class="momento-chip glass"
[class.active]="showNonValidati()"
(click)="toggleNonValidati()"
style="display: inline-flex; align-items: center; gap: 6px;">
<span>Non Validati</span>
<ion-icon
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
name="add-outline"
(click)="$event.stopPropagation()"
routerLink="/propose-canto"
class="non-validati-add-icon">
</ion-icon>
</div>
</ng-container>
<ng-container *ngIf="activeFilterType() !== 'lista_completa'">
<div
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
class="momento-chip glass"
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
</div>
</ng-container>
</div>
<!-- Instructions when playlist is empty -->
<div class="empty-playlist-container" *ngIf="activeFilterType() === 'playlist' && playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()">
<p class="empty-playlist-instruction outfit-font">
Per creare una playlist, seleziona il nr del canto che vuoi inserire nella playlist, riordinali e salvala con nome
</p>
</div>
</ion-toolbar>
</ion-header>
@@ -244,6 +396,7 @@
<div class="loading-wrapper">
<ion-spinner name="crescent" color="secondary"></ion-spinner>
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
<ion-progress-bar [value]="cantiService.progress() / 100" color="secondary" style="width: 200px; border-radius: 10px; height: 8px; margin-top: 8px;"></ion-progress-bar>
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
</div>
</div>
@@ -473,7 +626,6 @@
<div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)">
<div class="black-screen-content" (click)="$event.stopPropagation()">
<div (click)="isBlackOverlayDismissed.set(true)" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
<ion-icon name="car-outline" class="car-mode-icon"></ion-icon>
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
</div>
+386 -38
View File
@@ -48,32 +48,327 @@
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
.voice-search-btn {
--padding-start: 8px;
--padding-end: 8px;
.adv-search-btn, .voice-search-btn {
--padding-start: 6px;
--padding-end: 6px;
margin: 0;
height: 44px;
ion-icon {
font-size: 1.5rem;
}
font-size: 1.4rem;
}
}
.filter-actions-row {
.adv-search-btn.active {
opacity: 1;
}
}
.advanced-search-toggle-row {
display: flex;
align-items: center;
width: 100%;
overflow-x: auto;
gap: 12px;
padding: 8px 4px 8px 0;
justify-content: space-between;
padding: 0 2px;
gap: 4px;
flex-wrap: nowrap;
// Hide scrollbar but keep functionality
.toggle-row-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
width: 100%;
justify-content: flex-start;
}
.song-count-card {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--ion-color-secondary-rgb), 0.15);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
padding: 0 8px;
border-radius: 12px;
height: 30px;
font-size: 0.8rem;
font-weight: 700;
color: var(--ion-color-secondary);
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.advanced-search-link, .clear-advanced-link {
background: transparent;
border: none;
outline: none;
color: var(--ion-color-secondary);
font-size: 0.8rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
padding: 4px 5px;
border-radius: 8px;
transition: all 0.2s ease;
opacity: 0.9;
white-space: nowrap;
flex-shrink: 0;
&:hover, &:active {
opacity: 1;
background: rgba(var(--ion-color-secondary-rgb), 0.12);
}
ion-icon {
font-size: 1rem;
}
.inline-clear-btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 2px;
padding: 2px;
border-radius: 50%;
transition: transform 0.15s ease;
ion-icon {
font-size: 1.1rem;
color: var(--ion-color-danger, #ff4961);
}
&:hover {
transform: scale(1.2);
}
}
}
.clear-advanced-link {
color: var(--ion-color-danger, #ff4961);
font-weight: 500;
&:hover, &:active {
background: rgba(255, 73, 97, 0.12);
}
}
}
.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;
margin-left: 4px;
&:hover {
color: #ffffff;
}
}
.adv-mic-btn {
--padding-start: 4px;
--padding-end: 4px;
margin: 0 0 0 2px;
height: 32px;
min-height: 32px;
ion-icon {
font-size: 1.15rem;
}
}
}
}
}
@keyframes advSearchFadeIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:host-context(body.high-contrast) {
.advanced-search-link {
color: var(--ion-color-secondary) !important;
}
.clear-advanced-link {
color: var(--ion-color-danger, #d32f2f) !important;
}
.advanced-search-panel {
background: #ffffff !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
.adv-input-group {
.adv-label {
color: var(--ion-color-secondary, #d96b00) !important;
font-weight: 700 !important;
}
.adv-input-wrapper {
background: #f4f5f8 !important;
border: 1px solid #c0c4cc !important;
&:focus-within {
border-color: var(--ion-color-secondary, #d96b00) !important;
box-shadow: 0 0 0 2px rgba(217, 107, 0, 0.25) !important;
}
.adv-input-icon {
color: var(--ion-color-secondary, #d96b00) !important;
}
.adv-input {
color: #000000 !important;
font-weight: 600 !important;
&::placeholder {
color: #666666 !important;
font-weight: 400 !important;
}
}
.adv-clear-icon {
color: #666666 !important;
&:hover {
color: #000000 !important;
}
}
}
}
}
}
.filter-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
backdrop-filter: blur(12px);
margin-top: 4px;
.filter-card-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 34px;
.filter-row-label {
font-weight: 700;
font-size: 0.8rem;
color: var(--ion-color-secondary);
width: 68px;
flex-shrink: 0;
text-transform: capitalize;
letter-spacing: 0.3px;
opacity: 0.9;
}
.filter-row-items {
display: flex;
gap: 6px;
overflow-x: auto;
flex: 1;
align-items: center;
padding-bottom: 2px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
.empty-playlist-text {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
}
}
.filter-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
.song-count-card {
display: flex;
@@ -84,25 +379,17 @@
padding: 0 12px;
border-radius: 12px;
height: 32px;
font-size: 0.85rem;
font-weight: 800;
font-size: 0.82rem;
font-weight: 700;
color: var(--ion-color-secondary);
backdrop-filter: blur(10px);
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
.playlist-total-duration {
font-weight: 400;
opacity: 0.75;
font-size: 0.8rem;
letter-spacing: 0.01em;
}
}
.filter-buttons {
.footer-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
ion-button.filter-chip {
--border-radius: 20px;
@@ -123,27 +410,85 @@
padding: 4px;
margin-right: -8px;
cursor: pointer;
z-index: 100;
ion-icon {
font-size: 1.2rem;
font-size: 1.1rem;
margin: 0;
pointer-events: none;
}
&:active {
opacity: 0.5;
transform: scale(0.9);
}
}
}
}
}
ion-toolbar.playlist-toolbar {
--padding-top: 0px;
--padding-bottom: 8px;
--padding-start: 16px;
--padding-end: 16px;
--min-height: auto;
}
.playlist-actions-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 2px;
margin-bottom: 0px;
.playlist-duration-pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(var(--ion-color-secondary-rgb), 0.15);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
padding: 0 10px;
border-radius: 12px;
height: 30px;
font-size: 0.82rem;
font-weight: 700;
color: var(--ion-color-secondary);
backdrop-filter: blur(10px);
flex-shrink: 0;
ion-icon {
font-size: 0.95rem;
}
}
.playlist-title-badge {
display: inline-flex;
align-items: center;
padding: 0 10px;
height: 30px;
font-size: 0.9rem;
font-weight: 700;
color: var(--ion-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 50%;
margin: 0 8px;
flex-shrink: 1;
.playlist-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.selection-pill {
margin: 0;
margin-left: auto;
}
}
.transparent-list {
background: transparent !important;
padding-bottom: 120px; // Spazio per il player fisso
@@ -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;
}
}
}
+392 -31
View File
@@ -51,6 +51,18 @@ export class HomePage implements OnDestroy {
}
public searchQuery = signal<string>('');
public isAdvancedSearchOpen = signal<boolean>(false);
public isFilterCardOpen = signal<boolean>(false);
public isPlaylistCardOpen = signal<boolean>(false);
public searchTitle = signal<string>('');
public searchAuthor = signal<string>('');
public searchText = signal<string>('');
public searchNumber = signal<string>('');
public activeVoiceField = signal<'global' | 'title' | 'author' | 'text' | 'number'>('global');
public hasAdvancedSearchParams = computed(() => {
return !!(this.searchTitle().trim() || this.searchAuthor().trim() || this.searchText().trim() || this.searchNumber().trim());
});
public selectedLiturgico = signal<number | null>(null);
public selectedTematico = signal<number | null>(null);
public showOnlyMine = signal<boolean>(false);
@@ -58,6 +70,25 @@ export class HomePage implements OnDestroy {
public showSuggeriti = signal<boolean>(false);
public showValidati = signal<boolean>(false);
public showNonValidati = signal<boolean>(false);
public hasPlaylistCardParams = computed(() => {
return !!(
this.playlistService.activePlaylistId() !== null ||
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
);
});
public hasFilterCardParams = computed(() => {
return !!(
this.selectedLiturgico() !== null ||
this.selectedTematico() !== null ||
this.showOnlyMine() ||
this.showTopTen() ||
this.showSuggeriti() ||
this.showValidati() ||
this.showNonValidati()
);
});
public isMassCardExpanded = signal<boolean>(false);
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
public loadedThumbs = new Set<string>();
@@ -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<number, number>();
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
list = list.sort((a, b) => {
const numA = eseguitiMap.get(a.id_canti) || 0;
const numB = eseguitiMap.get(b.id_canti) || 0;
list = [...list].sort((a, b) => {
const numA = this.getEsecuzioniCount(a.id) || 0;
const numB = this.getEsecuzioniCount(b.id) || 0;
return numB - numA;
});
} else if (suggeriti) {
const suggMap = this.cantiLettureService.suggestionsMap();
list = list.sort((a, b) => {
list = [...list].sort((a, b) => {
const pesoA = suggMap.get(a.id_canti) || 0;
const pesoB = suggMap.get(b.id_canti) || 0;
return pesoB - pesoA;
@@ -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) {
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;
+38 -13
View File
@@ -4,36 +4,58 @@
<!-- Row 1: Number + Title -->
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;">
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
[style.fontSize.rem]="0.85 * fontSize()"
[style.width.px]="32 * fontSize()"
[style.height.px]="32 * fontSize()"
[style.lineHeight.px]="28 * fontSize()"
[style.borderRadius.px]="8 * fontSize()">
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span>
<span class="canto-title" style="font-weight: 700; font-size: 1.15rem; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
<span class="canto-title"
[style.fontSize.rem]="1.15 * fontSize()"
style="font-weight: 700; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span>
</span>
</div>
<!-- Row 2: Author -->
<div *ngIf="!isLandscapeActive()" style="display: block; width: 100%; padding-left: 40px; margin-top: 1px; margin-bottom: 2px;">
<span class="song-author" style="font-size: 0.95rem; color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
<div *ngIf="!isLandscapeActive()"
style="display: block; width: 100%; margin-top: 1px; margin-bottom: 2px;"
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
<span class="song-author"
[style.fontSize.rem]="0.95 * fontSize()"
style="color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
{{ canto()?.autore || 'Autore sconosciuto' }}
</span>
</div>
<!-- Row 3: Badges only -->
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal" style="padding-left: 40px; margin-top: 2px; width: 100%;">
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
style="margin-top: 2px; width: 100%;"
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
<div style="display: flex; align-items: center; gap: inherit;">
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
<span *ngIf="canto()?.durata" class="song-duration-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="time-outline"></ion-icon>
<span *ngIf="canto()?.durata" class="song-duration-badge"
[style.fontSize.rem]="0.75 * fontSize()"
[style.height.px]="26 * fontSize()"
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
{{ canto()?.durata }}
</span>
<span *ngIf="canto()?.bpm" class="song-bpm-badge" style="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"></ion-icon>
<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="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"></ion-icon>
<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>
@@ -234,6 +256,9 @@
[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>
<!-- Navigation -->
@@ -256,7 +281,8 @@
</ion-button>
<div class="transpose-indicator">
<ion-icon name="musical-note" color="secondary"></ion-icon>
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
<span class="val" *ngIf="tonality()">{{ tonality() }}</span>
<span class="val" *ngIf="transposeAmount() !== 0" style="opacity: 0.8; font-size: 0.75rem; margin-left: 2px;">({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})</span>
</div>
<ion-button fill="clear" size="small" (click)="transposeUp()">
<ion-icon slot="icon-only" name="add"></ion-icon>
@@ -293,7 +319,6 @@
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()">
<div class="black-screen-content" (click)="$event.stopPropagation()">
<div (click)="deactivateBlackScreen()" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
<ion-icon name="car-outline" class="car-mode-icon"></ion-icon>
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
</div>
+75 -16
View File
@@ -191,7 +191,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public faceDetector = inject(FaceDetectorService);
private meta = inject(Meta);
public enableCameraNavigation = signal<boolean>(false);
public enableCameraNavigation = computed(() => this.settingsService.cameraNavigationActive());
private songStartTime: number = 0;
@@ -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,7 +1356,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
startCameraNavigation() {
this.enableCameraNavigation.set(true);
this.settingsService.cameraNavigationActive.set(true);
this.initCameraTracking();
}
initCameraTracking() {
if (!this.settingsService.cameraNavigationActive()) return;
setTimeout(async () => {
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
@@ -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();
}
@@ -5,6 +5,9 @@
</ion-buttons>
<ion-title class="outfit-font">Gestione Playlist</ion-title>
<ion-buttons slot="end">
<ion-button (click)="exportPlaylistPdf()" [disabled]="localSongs.length === 0" title="Esporta PDF">
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
</ion-button>
<ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
</ion-button>
+38
View File
@@ -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();
}
}
@@ -85,9 +85,14 @@
<!-- TOOLBARS -->
<div class="toolbar-section">
<div class="horizontal-toolbar">
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
<ng-container *ngFor="let tag of commonTags">
<ion-button size="small" fill="outline" (click)="insertText(tag.start)">
{{ tag.label }}
</ion-button>
<ion-button *ngIf="tag.end" size="small" fill="outline" color="medium" (click)="insertText(tag.end)">
Fine {{ tag.label }}
</ion-button>
</ng-container>
</div>
</div>
@@ -153,6 +158,9 @@
<ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF">
<ion-icon name="document-attach-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="toggleRawPasteMode()" [color]="isRawPasteModeActive ? 'warning' : 'medium'" [title]="isRawPasteModeActive ? 'Incolla Raw: Attivo (il testo incollato non verrà filtrato)' : 'Attiva Incolla Raw (incolla senza filtri)'" style="font-size: 11px; font-weight: 800; font-family: 'Outfit', sans-serif;">
RAW
</ion-button>
</div>
</div>
<div class="editor-textarea-container">
@@ -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;
+48 -10
View File
@@ -1,7 +1,8 @@
import { Injectable, signal, inject, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Storage } from '@ionic/storage-angular';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../environments/environment';
export interface Suggestion {
id_canto?: number;
@@ -127,8 +128,19 @@ export class CantiLettureService {
const savedDate = localStorage.getItem('selected-mass-date');
if (savedDate) {
const masses = this.availableMasses();
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
// Only restore savedDate if it exists in available masses and is not outdated
const isValidAndNotPast = masses.some(m => m.date === savedDate && m.date >= todayStr);
if (isValidAndNotPast) {
this.selectedMassDate.set(savedDate);
}
}
// Fetch fresh data
await this.fetchData();
@@ -137,24 +149,45 @@ export class CantiLettureService {
async fetchData() {
try {
let fetchedData: CantiLettureData | null = null;
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
const isProduction = window.location.hostname.includes('canticristiani.it');
const primaryUrl = isProduction
? `${window.location.origin}${this.SECURE_JSON_URL}`
: 'https://www.canticristiani.it/api/cantiletture.json';
const fallbackUrl = isProduction
? 'https://www.canticristiani.it/api/cantiletture.json'
: this.JSON_URL;
// 1. Try static JSON endpoint first
try {
console.log('Fetching mass data from primary URL:', primaryUrl);
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
if (res && res.masses && res.week_end && res.week_end >= todayStr) {
fetchedData = res;
} else {
console.warn('Primary JSON data is missing or out of date:', res?.week_end);
}
} catch (err) {
console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err);
console.warn('Primary fetch failed:', err);
}
// 2. Fallback to direct API endpoint using Basic Auth credentials
if (!fetchedData) {
try {
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`));
} catch (fallbackErr) {
console.error('Fallback fetch also failed:', fallbackErr);
console.log('Fetching mass data from direct API:', this.JSON_URL);
const authUser = environment.apiAuthUser || 'canti';
const authPass = environment.apiAuthPass || 'antani2026';
const headers = new HttpHeaders({
'Authorization': 'Basic ' + btoa(`${authUser}:${authPass}`)
});
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${this.JSON_URL}?t=${Date.now()}`, { headers }));
if (res && res.masses) {
fetchedData = res;
}
} catch (authErr) {
console.error('Direct API fetch failed:', authErr);
}
}
@@ -194,13 +227,17 @@ export class CantiLettureService {
this.availableMasses.set(massesList);
// Always select today's mass if available on load, else find closest future date
if (massesList.length > 0) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayStr = `${year}-${month}-${day}`;
const currentSelected = this.selectedMassDate();
const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr);
if (!isCurrentValid) {
const match = massesList.find(m => m.date === todayStr);
if (match) {
this.selectedMassDate.set(match.date);
@@ -216,6 +253,7 @@ export class CantiLettureService {
}
}
}
}
setSelectedMass(date: string) {
this.selectedMassDate.set(date);
+76 -54
View File
@@ -9,7 +9,7 @@ export class FaceDetectorService {
public isTilted = signal<boolean>(false);
private stream: MediaStream | null = null;
private camera: any = null;
private animFrameId: number | null = null;
private faceMesh: any = null;
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
@@ -17,55 +17,49 @@ 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<void> {
return new Promise((resolve, reject) => {
if ((window as any).FaceMesh && (window as any).Camera) {
if ((window as any).FaceMesh) {
resolve();
return;
}
const cameraScript = document.createElement('script');
cameraScript.src = '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<void> {
if (this.isCameraActive()) return;
this.onTiltCallback = onTilt;
try {
await this.loadScripts();
// Request camera permissions and stream
const isStreamActive = this.stream &&
this.stream.active &&
this.stream.getVideoTracks().some(track => track.readyState === 'live');
if (!isStreamActive) {
// Request camera permissions and stream ONCE
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 320 },
@@ -74,17 +68,23 @@ export class FaceDetectorService {
},
audio: false
});
}
videoElement.srcObject = this.stream;
videoElement.setAttribute('playsinline', 'true');
(videoElement as any).playsInline = true;
videoElement.muted = true;
videoElement.play();
try {
await videoElement.play();
} catch (playErr) {
console.warn('[FaceDetector] Video play warning:', playErr);
}
if (!this.faceMesh) {
const FaceMeshLib = (window as any).FaceMesh;
const CameraLib = (window as any).Camera;
if (!FaceMeshLib || !CameraLib) {
throw new Error('MediaPipe libraries failed to initialize.');
if (!FaceMeshLib) {
throw new Error('MediaPipe FaceMesh library failed to initialize.');
}
this.faceMesh = new FaceMeshLib({
@@ -101,25 +101,14 @@ export class FaceDetectorService {
this.faceMesh.onResults((results: any) => {
this.processLandmarks(results);
});
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.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.');
}
}
@@ -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++) {
+567 -89
View File
@@ -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<string[]>([]);
public activeListName = signal<string | null>(null);
public activePlaylistId = signal<string | null>(null);
public filteredListIds = signal<string[]>([]);
public remotePlaylist = signal<any | null>(null);
public remoteCustomSongs = signal<any[]>([]);
@@ -665,10 +670,6 @@ export class PlaylistService {
const activeId = this.activePlaylistId() || Date.now().toString();
const isRemote = activeId.startsWith('remote_');
const buttons: any[] = [
{
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
handler: () => {
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
if (isRemote) {
let remoteUid = uid;
@@ -680,93 +681,15 @@ export class PlaylistService {
}
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
}
this.executeShare(shareLink, name);
}
}
];
if (!isRemote) {
buttons.push({
text: 'Modifica (Collaborazione / Backup)',
handler: () => {
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
this.executeShare(shareLink, name + ' (Editor)');
const modal = await this.modalCtrl.create({
component: SharePlaylistQrModalComponent,
componentProps: {
playlistName: name,
shareLink: shareLink
}
});
}
buttons.push({
text: 'Annulla',
role: 'cancel'
});
const alert = await this.alertCtrl.create({
header: 'Condividi Playlist',
message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:',
buttons: buttons
});
await alert.present();
}
private async executeShare(shareLink: string, name: string) {
// Generate QR using the link
const qrImage = await QRCode.toDataURL(shareLink, {
width: 400,
margin: 2,
color: {
dark: '#2d3436',
light: '#ffffff'
}
});
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
const res = await fetch(qrImage);
const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' });
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 ? `
<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;
}
.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();
}
}
+16
View File
@@ -50,6 +50,9 @@ export class SettingsService {
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
public enableVisualAutoscroll = signal<boolean>(true);
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
public cameraNavigationActive = signal<boolean>(false);
/** Preferenza notazione accordi: diesis o bemolle */
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
@@ -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());
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.07.14.1946';
export const VERSION = '2026.08.08.1614';
+8 -3
View File
@@ -1,8 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<html lang="it" class="notranslate">
<head>
<meta charset="utf-8"/>
<meta name="google" content="notranslate"/>
<title>CantiCristiani</title>
<base href="/"/>
@@ -73,7 +74,11 @@
if (options.percent !== undefined) {
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
if (bar) bar.style.width = pct + '%';
if (pctText) pctText.textContent = pct + '%';
if (bar && bar.parentElement) bar.parentElement.style.display = 'block';
if (pctText) {
pctText.style.display = 'block';
pctText.textContent = pct + '%';
}
}
if (options.isRedirect) {
@@ -82,7 +87,7 @@
if (phaseEl) phaseEl.style.display = 'none';
if (versionEl) versionEl.style.display = 'none';
if (titleEl) {
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device';
titleEl.textContent = 'Chiudi il browser è stata installata la app sul device';
titleEl.style.fontSize = '1.4rem';
titleEl.style.lineHeight = '1.5';
}