Miglioramenti generali layout player landscape/portrait, traduzione pulsanti navigazione, gestione aggiornamenti PWA e modifiche al posizionamento del pulsante aggiungi canto

This commit is contained in:
David Frassi
2026-06-14 22:14:41 +02:00
parent d023bb7d3f
commit b4b7e10c5f
20 changed files with 1554 additions and 535 deletions
+91 -12
View File
@@ -2,21 +2,100 @@
<ion-router-outlet></ion-router-outlet> <ion-router-outlet></ion-router-outlet>
<div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div> <div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div>
<!-- Unified Setup Loader Overlay --> <!-- PWA Redirect Overlay -->
<div *ngIf="!cantiService.firstLoadCompleted()" <div *ngIf="showRedirectOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: #121212; color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif;"> style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;"> <div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="margin-bottom: 25px; display: inline-block;"> <div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);"> <img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div> </div>
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; -webkit-font-smoothing: antialiased;">Setup in corso</h2> <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
<p style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; -webkit-font-smoothing: antialiased;">Configurazione iniziale e caricamento canti...</p> <p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
<div style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; -webkit-font-smoothing: antialiased; font-weight: 500;">Versione {{ version }}</div> Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
<div style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px;">Fase: Setup</div> </p>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div [style.width.%]="cantiService.progress()" style="background: #e67e22; height: 100%; transition: width 0.15s ease-out; border-radius: 10px;"></div> <!-- Premium CSS Spinner -->
<div style="display: inline-block; width: 40px; height: 40px; border: 3px solid rgba(230, 126, 34, 0.15); border-radius: 50%; border-top-color: #e67e22; animation: spin 1s ease-in-out infinite; margin-bottom: 30px;"></div>
<style>
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="openPwaManual()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
Apri Manualmente
</button>
<button (click)="stayInBrowser()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
Rimani nel browser
</button>
</div>
</div>
</div>
<!-- PWA Install Overlay -->
<div *ngIf="showInstallOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
<!-- Android Install Prompt -->
<div *ngIf="settingsService.isAndroid()"
style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser, aprirla all'istante ed usarla anche offline in chiesa!
</p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="triggerInstall()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
INSTALLA APP
</button>
<button (click)="closeInstallOverlay()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
Continua nel browser
</button>
</div>
</div>
<!-- iOS Install Guide -->
<div *ngIf="settingsService.isIos()"
style="padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="text-align: center; margin-bottom: 20px;">
<div style="margin-bottom: 15px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 70px; height: 70px; border-radius: 18px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2>
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; -webkit-font-smoothing: antialiased;">
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
</p>
</div>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
</div>
</div>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> .
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; width: 100%;">
<button (click)="closeInstallOverlay()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); text-align: center;">
Ho capito, continua
</button>
</div> </div>
<div style="font-size: 1.2rem; font-weight: 700; color: #e67e22; -webkit-font-smoothing: antialiased;">{{ cantiService.progress() }}%</div>
</div> </div>
</div> </div>
</ion-app> </ion-app>
+257 -92
View File
@@ -1,9 +1,12 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { ThemeService } from './services/theme.service'; import { ThemeService } from './services/theme.service';
import { CantiService } from './services/canti.service'; import { CantiService } from './services/canti.service';
import { SettingsService } from './services/settings.service';
import { VERSION } from './version'; import { VERSION } from './version';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { ToastController } from '@ionic/angular'; import { ToastController } from '@ionic/angular';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -14,10 +17,16 @@ import { ToastController } from '@ionic/angular';
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
private themeService = inject(ThemeService); // Ensures theme is initialized at boot private themeService = inject(ThemeService); // Ensures theme is initialized at boot
public cantiService = inject(CantiService); public cantiService = inject(CantiService);
public settingsService = inject(SettingsService);
public version = VERSION; public version = VERSION;
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
private swUpdate = inject(SwUpdate);
public showRedirectOverlay = signal<boolean>(false);
public showInstallOverlay = signal<boolean>(false);
public protocolLink = '';
constructor() { constructor() {
// Gli aggiornamenti automatici e periodici sono stati rimossi. // Gli aggiornamenti automatici e periodici sono stati rimossi.
@@ -25,7 +34,28 @@ export class AppComponent implements OnInit {
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage. // tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
} }
ngOnInit() { async ngOnInit() {
// 1. Allineamento istantaneo alla versione remota
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
phase: 'Fase: Verifica Versione',
desc: 'Verifica della versione più recente in corso...'
});
}
const updated = await this.checkVersionSync();
if (updated) {
return; // Reloading, skip further setup
}
// Set signal indicating startup version check is complete
this.settingsService.isVersionCheckComplete.set(true);
// Hide loader if canti are also already loaded
if (this.cantiService.firstLoadCompleted() && (window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
const protocolUrl = params['url']; const protocolUrl = params['url'];
if (protocolUrl && protocolUrl.startsWith('web+canti:')) { if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
@@ -55,92 +85,234 @@ export class AppComponent implements OnInit {
this.checkAndRedirectToPwa(); this.checkAndRedirectToPwa();
} }
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.
try {
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
const response = await Promise.race([
fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' }),
new Promise<Response | null>((resolve) => setTimeout(() => resolve(null), 5000))
]);
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();
// Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW)
if (this.swUpdate.isEnabled) {
try {
const hasSwUpdate = await Promise.race([
this.swUpdate.checkForUpdate(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]);
if (hasSwUpdate) {
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...');
await Promise.race([
this.swUpdate.activateUpdate(),
new Promise<void>((resolve) => setTimeout(resolve, 5000))
]);
}
} catch (e) {
console.warn('[PWA-Update] SwUpdate durante mismatch fallito:', e);
}
}
// Aggiorna anche la registrazione SW direttamente (doppia sicurezza)
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.update();
} catch (e) {
console.warn('[PWA-Update] SW registration.update fallito:', e);
}
}
// Disattiva service worker attivi per forzare il refresh completo
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
}
// Cancella le cache del browser
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
overlay.finish();
// Ricarica con parametro cache-busting per forzare l'allineamento remoto
const url = new URL(window.location.href);
url.searchParams.set('update_cb', Date.now().toString());
window.location.replace(url.toString());
return true;
} else {
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
}
}
} catch (e) {
console.warn('[PWA-Update] version.json check fallito:', e);
}
// 2. Fallback: Prova SwUpdate nel caso in cui il controllo version.json sia fallito o sia stato servito dalla cache
if (this.swUpdate.isEnabled) {
try {
console.log('[PWA-Update] Verifica aggiornamenti via SwUpdate all\'avvio...');
let activated = false;
let overlay: any = null;
const activateAndReload = async () => {
if (activated) return;
activated = true;
try {
await this.swUpdate.activateUpdate();
} catch (e) {
console.warn('[PWA-Update] activateUpdate fallito all\'avvio:', e);
}
// Deregistra i vecchi SW e cancella le cache per un ricaricamento pulito
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
}
}
if ('caches' in window) {
const keys = await caches.keys();
for (const key of keys) {
await caches.delete(key);
}
}
if (overlay) overlay.finish();
setTimeout(() => {
const url = new URL(window.location.href);
url.searchParams.set('update_cb', Date.now().toString());
window.location.replace(url.toString());
}, 600);
};
// Sottoscrivi PRIMA di verificare l'aggiornamento per evitare race condition
const sub = this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio');
activateAndReload();
});
// Concedi fino a 8 secondi al controllo SW — le connessioni mobili possono essere lente
const hasUpdate = await Promise.race([
this.swUpdate.checkForUpdate(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
]);
if (hasUpdate) {
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...');
overlay = showFullscreenUpdateOverlay();
// Timeout di sicurezza di 25 secondi: se VERSION_READY non arriva, attiva comunque
setTimeout(() => {
console.log('[PWA-Update] Safety timeout raggiunto all\'avvio, procedo...');
sub.unsubscribe();
activateAndReload();
}, 25000);
return true; // Attendi il ricaricamento
} else {
sub.unsubscribe();
}
} catch (err) {
console.warn('[PWA-Update] Controllo SwUpdate fallito all\'avvio:', err);
}
}
return false;
}
async checkAndRedirectToPwa() { async checkAndRedirectToPwa() {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone; const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) { if (isStandalone) {
localStorage.setItem('pwa-installed', 'true');
return; return;
} }
// Check if we have sharing/navigation query parameters const search = window.location.search;
const urlParams = new URLSearchParams(window.location.search); const path = window.location.pathname;
const hasSharing = urlParams.has('import') || urlParams.has('playlist-uid') || urlParams.has('restore-uid') || urlParams.has('id'); this.protocolLink = `web+canti://open${path}${search}`;
if (hasSharing) { // Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo
let isInstalled = false; let isInstalled = localStorage.getItem('pwa-installed') === 'true';
if ('getInstalledRelatedApps' in navigator) { if (!isInstalled && 'getInstalledRelatedApps' in navigator) {
try { try {
const relatedApps = await (navigator as any).getInstalledRelatedApps(); const relatedApps = await (navigator as any).getInstalledRelatedApps();
isInstalled = relatedApps.length > 0; isInstalled = relatedApps.length > 0;
} catch (e) { if (isInstalled) {
console.warn('Failed to check installed apps:', e); localStorage.setItem('pwa-installed', 'true');
} }
} } catch (e) {
console.warn('Failed to check installed apps:', e);
const search = window.location.search;
const path = window.location.pathname;
const protocolLink = `web+canti://open${path}${search}`;
if (isInstalled) {
window.location.href = protocolLink;
} else {
const toast = await this.toastCtrl.create({
header: 'Apri nell\'App CantiCristiani',
message: 'Usa la PWA installata per visualizzare questo contenuto ed evitare la cache del browser.',
position: 'top',
color: 'warning',
buttons: [
{
text: 'APRI APP',
handler: () => {
window.location.href = protocolLink;
}
},
{
text: 'Nascondi',
role: 'cancel'
}
]
});
await toast.present();
} }
} }
if (isInstalled) {
const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true';
if (!skipRedirect) {
this.showRedirectOverlay.set(true);
// Tentiamo il reindirizzamento automatico
setTimeout(() => {
window.location.href = this.protocolLink;
}, 800);
}
} else {
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
if (!skipInstall && (this.settingsService.isAndroid() || this.settingsService.isIos())) {
this.showInstallOverlay.set(true);
}
}
}
openPwaManual() {
window.location.href = this.protocolLink;
}
stayInBrowser() {
sessionStorage.setItem('skip-pwa-redirect', 'true');
this.showRedirectOverlay.set(false);
}
closeInstallOverlay() {
sessionStorage.setItem('skip-pwa-install', 'true');
this.showInstallOverlay.set(false);
}
async triggerInstall() {
await this.settingsService.installPwa();
this.closeInstallOverlay();
} }
} }
export function showFullscreenUpdateOverlay() { export function showFullscreenUpdateOverlay() {
const overlay = document.createElement('div'); if ((window as any).PwaLoader) {
overlay.id = 'pwa-update-overlay'; (window as any).PwaLoader.show();
overlay.style.position = 'fixed'; (window as any).PwaLoader.update({
overlay.style.top = '0'; title: 'Download aggiornamento',
overlay.style.left = '0'; phase: 'Fase: Download',
overlay.style.width = '100vw'; desc: 'Scaricamento della nuova versione...',
overlay.style.height = '100vh'; percent: 0
overlay.style.backgroundColor = '#121212'; });
overlay.style.color = '#ffffff'; }
overlay.style.display = 'flex';
overlay.style.flexDirection = 'column';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.zIndex = '99999';
overlay.style.fontFamily = "'Outfit', sans-serif";
overlay.style.transition = 'opacity 0.5s ease';
overlay.innerHTML = `
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Download aggiornamento</h2>
<p style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Scaricamento della nuova versione...</p>
<div id="pwa-update-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Download</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-update-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-update-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div>
`;
document.body.appendChild(overlay);
// Fetch remote version to display the version being downloaded // Fetch remote version to display the version being downloaded
fetch(`/version.json?cb=${Date.now()}`) fetch(`/version.json?cb=${Date.now()}`)
@@ -149,38 +321,31 @@ export function showFullscreenUpdateOverlay() {
throw new Error('Fallback'); throw new Error('Fallback');
}) })
.then(data => { .then(data => {
const versionEl = document.getElementById('pwa-update-version'); if (data && data.version && (window as any).PwaLoader) {
if (data && data.version && versionEl) { (window as any).PwaLoader.update({
versionEl.textContent = 'Versione ' + data.version; version: 'Versione ' + data.version
});
} }
}) })
.catch(() => { .catch(() => {});
const versionEl = document.getElementById('pwa-update-version');
if (versionEl) {
versionEl.textContent = '';
}
});
let percent = 0; let percent = 0;
const interval = setInterval(() => { const interval = setInterval(() => {
if (percent < 95) { if (percent < 95) {
percent += Math.floor(Math.random() * 5) + 2; percent += Math.floor(Math.random() * 5) + 2;
if (percent > 95) percent = 95; if (percent > 95) percent = 95;
updatePercent(percent); if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent });
}
} }
}, 150); }, 150);
function updatePercent(val: number) {
const bar = document.getElementById('pwa-update-bar');
const txt = document.getElementById('pwa-update-percent');
if (bar) bar.style.width = val + '%';
if (txt) txt.textContent = val + '%';
}
return { return {
finish: () => { finish: () => {
clearInterval(interval); clearInterval(interval);
updatePercent(100); if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: 100 });
}
} }
}; };
} }
+24 -57
View File
@@ -19,9 +19,6 @@
<ion-button (click)="importPlaylist()" class="add-btn"> <ion-button (click)="importPlaylist()" class="add-btn">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon> <ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button routerLink="/propose-canto" class="add-btn" *ngIf="!playlistService.selectionMode() && settingsService.showEditor()">
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
</ion-button>
<ion-button routerLink="/settings" class="settings-btn"> <ion-button routerLink="/settings" class="settings-btn">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon> <ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button> </ion-button>
@@ -48,6 +45,19 @@
{{ filteredCanti().length }} {{ filteredCanti().length }}
</div> </div>
<div class="filter-buttons"> <div class="filter-buttons">
<ion-button
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
size="small"
(click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()"
class="filter-chip">
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length > 0"></ion-icon>
<ion-icon slot="end" name="add-circle-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length === 0"></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()"> <div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<ion-button <ion-button
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'" [fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
@@ -116,19 +126,6 @@
</span> </span>
</ion-button> </ion-button>
<ion-button
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
size="small"
(click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()"
class="filter-chip">
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length > 0"></ion-icon>
<ion-icon slot="end" name="add-circle-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length === 0"></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>
<ion-button <ion-button
[fill]="showTopTen() ? 'solid' : 'outline'" [fill]="showTopTen() ? 'solid' : 'outline'"
size="small" size="small"
@@ -186,8 +183,16 @@
<div <div
class="momento-chip glass" class="momento-chip glass"
[class.active]="showNonValidati()" [class.active]="showNonValidati()"
(click)="toggleNonValidati()"> (click)="toggleNonValidati()"
Non Validati 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"
style="font-size: 1.1rem; color: #000; font-weight: bold; background: rgba(0,0,0,0.1); border-radius: 50%; padding: 2px;">
</ion-icon>
</div> </div>
</ng-container> </ng-container>
@@ -213,28 +218,6 @@
(click)="onInteraction()"> (click)="onInteraction()">
<div class="ion-padding no-padding-top"> <div class="ion-padding no-padding-top">
<!-- Premium PWA Android Install Banner -->
<div class="pwa-android-banner glass ion-margin-bottom" *ngIf="showAndroidBanner()">
<div class="banner-inner">
<div class="banner-icon-wrapper">
<ion-icon name="cloud-download-outline" class="banner-icon"></ion-icon>
</div>
<div class="banner-text-wrapper">
<h3 class="banner-title outfit-font">Installa la nostra App!</h3>
<p class="banner-subtitle outfit-font">
Accedi a tutti i canti all'istante, anche offline, direttamente dalla tua home screen.
</p>
</div>
<div class="banner-actions">
<ion-button fill="solid" size="small" color="secondary" class="install-action-btn outfit-font" (click)="installAndroidPwa()">
Installa
</ion-button>
<ion-button fill="clear" size="small" class="dismiss-action-btn" (click)="dismissAndroidBanner($event)">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
</div>
</div>
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container"> <div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
<div class="loading-wrapper"> <div class="loading-wrapper">
@@ -440,20 +423,4 @@
</ion-toolbar> </ion-toolbar>
</ion-footer> </ion-footer>
<!-- Premium PWA iOS Tooltip -->
<div class="pwa-ios-tooltip-wrapper" [class.has-player]="youtubePlayerService.currentCantoId()" *ngIf="showIosTooltip()">
<div class="pwa-ios-tooltip glass">
<div class="tooltip-header">
<span class="tooltip-title outfit-font">Installa su iPhone</span>
<ion-button fill="clear" size="small" class="dismiss-btn" (click)="dismissIosTooltip($event)">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
<div class="tooltip-body">
<p class="tooltip-text outfit-font">
Tocca il pulsante Condividi <span class="safari-icon-inline"><ion-icon name="share-outline"></ion-icon></span> in basso e seleziona <strong>"Aggiungi a schermata Home"</strong>.
</p>
</div>
<div class="tooltip-arrow"></div>
</div>
</div>
+93 -69
View File
@@ -43,9 +43,6 @@ export class HomePage implements OnDestroy {
public isAddingSongs = signal<boolean>(false); public isAddingSongs = signal<boolean>(false);
public reorderList = signal<any[]>([]); public reorderList = signal<any[]>([]);
public limit = signal<number>(30); public limit = signal<number>(30);
public showAndroidBanner = signal<boolean>(false);
public showIosTooltip = signal<boolean>(false);
public hasUpdateAvailable = signal<boolean>(false); public hasUpdateAvailable = signal<boolean>(false);
public fontSize = signal<number>(1.0); public fontSize = signal<number>(1.0);
@@ -77,6 +74,7 @@ export class HomePage implements OnDestroy {
private firstInteraction = true; private firstInteraction = true;
private updatePollInterval: any = null; private updatePollInterval: any = null;
private queryParamsSubscription: any = null;
async checkForAppUpdate(event?: Event) { async checkForAppUpdate(event?: Event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
@@ -117,14 +115,32 @@ export class HomePage implements OnDestroy {
await registration.update(); await registration.update();
} }
let sub: any = null;
let readyPromise: Promise<void> | undefined = undefined;
if (this.swUpdate.isEnabled) {
readyPromise = new Promise<void>((resolve) => {
sub = this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
resolve();
});
});
}
if (this.swUpdate.isEnabled) { if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate(); const swFoundUpdate = await this.swUpdate.checkForUpdate();
if (swFoundUpdate) { if (swFoundUpdate) {
await this.applyUpdateAndReload(); await this.applyUpdateAndReload(readyPromise, sub);
return true; return true;
} }
} }
if (sub) sub.unsubscribe();
const versionMismatch = await this.checkVersionJson(); const versionMismatch = await this.checkVersionJson();
if (versionMismatch) { if (versionMismatch) {
await this.applyUpdateAndReload(); await this.applyUpdateAndReload();
@@ -144,7 +160,7 @@ export class HomePage implements OnDestroy {
} }
} }
private async applyUpdateAndReload() { private async applyUpdateAndReload(readyPromise?: Promise<void>, subscription?: any) {
const overlay = showFullscreenUpdateOverlay(); const overlay = showFullscreenUpdateOverlay();
let activated = false; let activated = false;
@@ -164,20 +180,19 @@ export class HomePage implements OnDestroy {
}, 600); }, 600);
}; };
if (this.swUpdate.isEnabled) { if (readyPromise) {
this.swUpdate.versionUpdates Promise.race([
.pipe( readyPromise,
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), new Promise((resolve) => setTimeout(resolve, 25000))
first() ]).then(() => {
) if (subscription) subscription.unsubscribe();
.subscribe(() => { activateAndReload();
activateAndReload(); });
}); } else {
setTimeout(() => {
activateAndReload();
}, 1000);
} }
setTimeout(() => {
activateAndReload();
}, 6000);
} }
private async checkVersionJson(): Promise<boolean> { private async checkVersionJson(): Promise<boolean> {
@@ -429,21 +444,7 @@ export class HomePage implements OnDestroy {
this.checkUpdateStatus(); this.checkUpdateStatus();
}, 30000); }, 30000);
// Check if install prompts should be visible
const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true';
const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true';
this.showAndroidBanner.set(
this.settingsService.isAndroid() &&
!this.settingsService.isStandalone() &&
!androidDismissed
);
this.showIosTooltip.set(
this.settingsService.isIos() &&
!this.settingsService.isStandalone() &&
!iosDismissed
);
// Track initial community filter state to avoid clearing during the initial run of the effect // Track initial community filter state to avoid clearing during the initial run of the effect
let prevFilterActive = this.comunitaService.isFilterActive(); let prevFilterActive = this.comunitaService.isFilterActive();
@@ -482,17 +483,43 @@ export class HomePage implements OnDestroy {
} }
}); });
this.route.queryParams.subscribe(params => { let queryParamsSubscribed = false;
if (params['import']) { effect(() => {
this.handleImport(params['import']); if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) {
queryParamsSubscribed = true;
this.queryParamsSubscription = this.route.queryParams.subscribe(params => {
if (params['import']) {
this.handleImport(params['import']);
}
if (params['playlist-uid']) {
this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']);
}
if (params['restore-uid']) {
this.handleRemoteRestore(params['restore-uid']);
}
});
} }
if (params['playlist-uid']) { }, { allowSignalWrites: true });
this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']);
let hasAutoActivatedPlaylist = false;
effect(() => {
const lastPl = this.playlistService.lastPlaylist();
const versionReady = this.settingsService.isVersionCheckComplete();
if (versionReady && lastPl && !hasAutoActivatedPlaylist) {
const params = this.route.snapshot.queryParams;
if (!params['id'] && !params['import'] && !params['playlist-uid'] && !params['restore-uid']) {
hasAutoActivatedPlaylist = true;
this.selectPlaylist(lastPl);
this.activeFilterType.set('playlist');
if (lastPl.ids && lastPl.ids.length > 0) {
this.goToCanto(lastPl.ids[0]);
}
} else {
hasAutoActivatedPlaylist = true;
}
} }
if (params['restore-uid']) { }, { allowSignalWrites: true });
this.handleRemoteRestore(params['restore-uid']);
}
});
let prevSelectionMode = false; let prevSelectionMode = false;
@@ -550,33 +577,7 @@ export class HomePage implements OnDestroy {
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
} }
dismissAndroidBanner(event?: Event) {
if (event) event.stopPropagation();
this.showAndroidBanner.set(false);
localStorage.setItem('pwa-android-dismissed', 'true');
}
dismissIosTooltip(event?: Event) {
if (event) event.stopPropagation();
this.showIosTooltip.set(false);
localStorage.setItem('pwa-ios-dismissed', 'true');
}
async installAndroidPwa() {
if (this.settingsService.deferredPrompt()) {
await this.settingsService.installPwa();
this.dismissAndroidBanner();
} else {
const toast = await this.toastCtrl.create({
message: 'Tocca il menu del browser (i tre puntini in alto a destra) e seleziona "Aggiungi a schermata Home" o "Installa app".',
duration: 6000,
position: 'bottom',
color: 'secondary',
buttons: [{ text: 'OK', role: 'cancel' }]
});
await toast.present();
}
}
async handleImport(base64: string) { async handleImport(base64: string) {
try { try {
@@ -631,7 +632,11 @@ export class HomePage implements OnDestroy {
if (this.playlistService.processImportJson(json)) { if (this.playlistService.processImportJson(json)) {
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge' }); this.activeFilterType.set('playlist');
await this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge', replaceUrl: true });
if (json.ids && json.ids.length > 0) {
this.goToCanto(json.ids[0]);
}
} }
} catch (e) { } catch (e) {
console.error('Failed to import playlist', e); console.error('Failed to import playlist', e);
@@ -651,6 +656,7 @@ export class HomePage implements OnDestroy {
}); });
await loading.present(); await loading.present();
let hasNavigatedToCanto = false;
try { try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' }); const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) { if (!response.ok) {
@@ -712,6 +718,7 @@ export class HomePage implements OnDestroy {
// Select it immediately // Select it immediately
this.selectPlaylist(selectedPl); this.selectPlaylist(selectedPl);
this.activeFilterType.set('playlist');
await loading.dismiss(); await loading.dismiss();
@@ -721,6 +728,12 @@ export class HomePage implements OnDestroy {
color: 'success' color: 'success'
}); });
await toast.present(); await toast.present();
if (selectedPl.ids && selectedPl.ids.length > 0) {
hasNavigatedToCanto = true;
await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge', replaceUrl: true });
this.goToCanto(selectedPl.ids[0]);
}
} else { } else {
throw new Error('Formato dati non valido.'); throw new Error('Formato dati non valido.');
} }
@@ -734,7 +747,9 @@ export class HomePage implements OnDestroy {
}); });
await alert.present(); await alert.present();
} finally { } finally {
this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge' }); if (!hasNavigatedToCanto) {
this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge' });
}
} }
} }
@@ -839,6 +854,9 @@ export class HomePage implements OnDestroy {
if (this.updatePollInterval) { if (this.updatePollInterval) {
clearInterval(this.updatePollInterval); clearInterval(this.updatePollInterval);
} }
if (this.queryParamsSubscription) {
this.queryParamsSubscription.unsubscribe();
}
} }
onSearch(event: any) { onSearch(event: any) {
@@ -1408,6 +1426,12 @@ export class HomePage implements OnDestroy {
// Automatically save to the device! // Automatically save to the device!
this.playlistService.savePlaylist(playlistName, ids); this.playlistService.savePlaylist(playlistName, ids);
// Activate the playlist filter and open the first song!
this.activeFilterType.set('playlist');
if (ids.length > 0) {
this.goToCanto(ids[0]);
}
} }
} }
+3 -3
View File
@@ -8,7 +8,7 @@
<!-- Previous line --> <!-- Previous line -->
<div *ngIf="displayLines().prev" class="prev-line"> <div *ngIf="displayLines().prev" class="prev-line">
<ng-container *ngIf="showChords(); else prevText"> <ng-container *ngIf="showChords(); else prevText">
<span *ngFor="let seg of displayLines().prev!.segments" class="chord-segment"> <span *ngFor="let seg of displayLines().prev!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().prev!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span> <span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span> <span class="seg-text">{{ seg.text }}</span>
</span> </span>
@@ -19,7 +19,7 @@
<!-- Active line --> <!-- Active line -->
<div *ngIf="displayLines().current" class="active-line outfit-font"> <div *ngIf="displayLines().current" class="active-line outfit-font">
<ng-container *ngIf="showChords(); else currentText"> <ng-container *ngIf="showChords(); else currentText">
<span *ngFor="let seg of displayLines().current!.segments" class="chord-segment"> <span *ngFor="let seg of displayLines().current!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().current!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span> <span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span> <span class="seg-text">{{ seg.text }}</span>
</span> </span>
@@ -30,7 +30,7 @@
<!-- Next line --> <!-- Next line -->
<div *ngIf="displayLines().next" class="next-line"> <div *ngIf="displayLines().next" class="next-line">
<ng-container *ngIf="showChords(); else nextText"> <ng-container *ngIf="showChords(); else nextText">
<span *ngFor="let seg of displayLines().next!.segments" class="chord-segment"> <span *ngFor="let seg of displayLines().next!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().next!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span> <span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span> <span class="seg-text">{{ seg.text }}</span>
</span> </span>
+1 -1
View File
@@ -82,7 +82,7 @@ export class DisplayPage implements OnInit, OnDestroy {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private cantiService = inject(CantiService); private cantiService = inject(CantiService);
private lyricsParser = inject(LyricsParserService); public lyricsParser = inject(LyricsParserService);
private comunitaService = inject(ComunitaService); private comunitaService = inject(ComunitaService);
constructor() { constructor() {
+93 -59
View File
@@ -11,14 +11,6 @@
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span> <span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span> <span>{{ canto()?.titolo || 'Player' }}</span>
<span style="display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;">
<ion-button fill="clear" (click)="prevSong()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px; width: 28px; min-height: 28px;">
<ion-icon name="chevron-back" color="secondary" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="nextSong()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px; width: 28px; min-height: 28px;">
<ion-icon name="chevron-forward" color="secondary" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
</span>
</span> </span>
</div> </div>
</ion-title> </ion-title>
@@ -26,10 +18,10 @@
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()"> <div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon> <ion-icon name="cloud-offline-outline"></ion-icon>
</div> </div>
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> <ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon> <ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" (click)="toggleChords()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> <ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" <ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'" [name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
[color]="showChords() ? 'secondary' : 'medium'" [color]="showChords() ? 'secondary' : 'medium'"
@@ -46,24 +38,59 @@
<div class="lyrics-container" <div class="lyrics-container"
[style.fontSize.rem]="fontSize()" [style.fontSize.rem]="fontSize()"
[class.full-screen-container]="settingsService.fullscreenMode()" [class.full-screen-container]="settingsService.fullscreenMode()"
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
(touchstart)="onTouchStart($event)" (touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)" (touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()"> (touchend)="onTouchEnd()">
<!-- Landscape Side Controls (Scrollable) --> <!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()"> <div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container"> <div class="side-scroll-container">
<!-- Karaoke Line Advancer (Avanzatore di riga) -->
<div class="side-group" style="margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;"> <!-- Navigation Group -->
<ion-button fill="clear" (click)="restart()" style="height: 48px; margin: 0;"> <div class="side-group">
<ion-icon name="arrow-up-circle" color="secondary" style="font-size: 1.8rem;"></ion-icon> <ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
prv
</ion-button> </ion-button>
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0" style="height: 48px; margin: 0;"> </div>
<ion-icon name="chevron-up" color="secondary" style="font-size: 1.8rem;"></ion-icon> <div class="side-group">
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-up" color="secondary"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1" style="height: 48px; margin: 0;"> </div>
<ion-icon name="chevron-down" color="secondary" style="font-size: 1.8rem;"></ion-icon>
<div class="side-group">
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-down" color="secondary"></ion-icon>
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="landscape-playlist-btn next-btn">
nxt
</ion-button>
</div>
<!-- Zoom Group -->
<div class="side-group">
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Autoscroll Standard Group -->
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()">
<ion-button fill="clear" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="side-indicator" (click)="toggleAutoscroll()">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
<span class="side-val">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button> </ion-button>
</div> </div>
@@ -86,8 +113,8 @@
[class.active]="isActiveLine(si, li)"> [class.active]="isActiveLine(si, li)">
<!-- Chord mode: show chords above text --> <!-- Chord mode: show chords above text -->
<ng-container *ngIf="showChords(); else textOnly"> <ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
<span *ngFor="let seg of line.segments" class="chord-segment"> <span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span> <span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span> <span class="seg-text">{{ seg.text }}</span>
</span> </span>
@@ -99,7 +126,7 @@
</ng-template> </ng-template>
</div> </div>
</div> </div>
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima allo schermo --> <!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima sullo schermo -->
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div> <div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
</div> </div>
@@ -107,33 +134,36 @@
</ion-content> </ion-content>
<ion-footer class="ion-no-border"> <ion-footer class="ion-no-border">
<!-- Audio Player Toolbar --> <!-- Audio Player / Playlist Navigation Toolbar -->
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()"> <ion-toolbar class="global-player-toolbar glass">
<div class="player-content"> <div class="player-content">
<div class="controls-row"> <div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn"> <ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon> <ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn"> <ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon> <ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
</ion-button> <ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<ion-range <ion-range
[min]="0" [min]="0"
[max]="youtubePlayerService.videoDuration()" [max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()" [value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)" (ionChange)="onSeek($event)"
color="secondary" color="secondary"
class="global-range"> class="global-range">
</ion-range> </ion-range>
</ng-container>
<ng-template #noAudioMsg>
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
Nessun audio disponibile
</div>
</ng-template>
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn"> <ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon> <ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
</ion-button>
<ion-button fill="clear" color="medium" (click)="stopVideo($event)" class="close-btn">
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
</ion-button> </ion-button>
</div> </div>
</div> </div>
@@ -167,6 +197,18 @@
</div> </div>
</div> </div>
<!-- Navigation -->
<div class="group">
<ion-button fill="clear" size="small" (click)="restart()">
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-back" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-forward" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Transposition (Only in chords mode) --> <!-- Transposition (Only in chords mode) -->
<div class="group" *ngIf="showChords()"> <div class="group" *ngIf="showChords()">
@@ -182,38 +224,30 @@
</ion-button> </ion-button>
</div> </div>
<!-- Navigation -->
<div class="group">
<ion-button fill="clear" size="small" (click)="restart()">
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-back" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-forward" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Zoom --> <!-- Zoom -->
<div class="group"> <div class="group">
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= 0.6"> <ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon> <ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= 5.0"> <ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon> <ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button> </ion-button>
</div> </div>
<!-- Actions --> <!-- YouTube Link in Portrait Footer -->
<div class="group" *ngIf="connectivityService.isOnline()"> <div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()"> <ion-button fill="clear" size="small" (click)="openYoutube()">
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon> <ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
</ion-button> </ion-button>
</div> </div>
</div> </div>
</ion-toolbar> </ion-toolbar>
</ion-footer> </ion-footer>
<!-- Black Screen Overlay -->
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()"></div>
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) --> <!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video> <video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
+119 -17
View File
@@ -172,6 +172,9 @@
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 1.5em; min-height: 1.5em;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
&.active { &.active {
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
@@ -187,6 +190,10 @@
vertical-align: bottom; vertical-align: bottom;
margin-right: 0.2em; margin-right: 0.2em;
&.contiguous-next {
margin-right: 0 !important;
}
.chord { .chord {
font-size: 0.75em; font-size: 0.75em;
font-weight: 700; font-weight: 700;
@@ -299,7 +306,6 @@
100% { transform: scale(0.9); opacity: 0.6; } 100% { transform: scale(0.9); opacity: 0.6; }
} }
// Fullscreen and Orientation
@media (orientation: landscape) { @media (orientation: landscape) {
// Always hide footer in landscape as we have side controls // Always hide footer in landscape as we have side controls
ion-footer { ion-footer {
@@ -309,22 +315,19 @@
.lyrics-container { .lyrics-container {
height: 100%; height: 100%;
padding-top: 4px; // Minimized padding-top: 4px; // Minimized
padding-bottom: 8px !important;
padding-right: 90px !important; // More room for side controls and zoom padding-right: 90px !important; // More room for side controls and zoom
} }
.lyrics-view {
max-width: 100% !important;
}
ion-content { ion-content {
--offset-bottom: 0px !important; --offset-bottom: 0px !important;
} }
} }
ion-content.full-screen-content {
--offset-bottom: 0px !important;
@media (orientation: portrait) {
--offset-bottom: 48px !important; // Footer height
}
}
.landscape-side-controls { .landscape-side-controls {
display: none !important; // Strict hidden in portrait display: none !important; // Strict hidden in portrait
@@ -333,9 +336,9 @@ ion-content.full-screen-content {
flex-direction: column; flex-direction: column;
position: fixed; position: fixed;
right: 0; right: 0;
top: 44px !important; // Align with header top: 56px !important; // Align below header
bottom: 0; bottom: 0;
width: 60px; width: 50px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-left: 1px solid rgba(255, 255, 255, 0.1); border-left: 1px solid rgba(255, 255, 255, 0.1);
@@ -347,8 +350,10 @@ ion-content.full-screen-content {
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 0; justify-content: flex-start;
gap: 16px; align-items: center;
padding: 8px 0;
gap: 12px;
&::-webkit-scrollbar { display: none; } &::-webkit-scrollbar { display: none; }
} }
@@ -356,23 +361,82 @@ ion-content.full-screen-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 10px; // Reduced for a more compact layout gap: 8px; // Reduced for a more compact layout
padding-bottom: 20px; padding-bottom: 12px;
background: rgba(255, 255, 255, 0.05);
margin: 0 2px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 6px;
ion-button { ion-button {
--padding-start: 0; --padding-start: 0;
--padding-end: 0; --padding-end: 0;
margin: 0; margin: 0;
height: 48px; height: 38px;
ion-icon { font-size: 1.8rem; } width: 38px;
ion-icon { font-size: 1.5rem; }
} }
} }
.vertical-range-container {
height: 120px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 8px 0;
}
.vertical-range {
transform: rotate(-90deg);
width: 120px;
--bar-height: 4px;
--knob-size: 14px;
padding: 0;
margin: 0;
}
.side-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 700;
color: var(--ion-color-secondary);
padding: 4px 0;
cursor: pointer;
ion-icon {
font-size: 1.0rem;
}
&.tilted {
color: var(--ion-color-success, #2ed573);
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
}
}
.side-val {
font-size: 0.7rem;
font-weight: 700;
}
.side-divider { .side-divider {
display: none; display: none;
} }
} }
ion-content.full-screen-content {
--offset-bottom: 0px !important;
@media (orientation: portrait) {
--offset-bottom: 48px !important; // Footer height
}
}
.mic-sensitivity-overlay { .mic-sensitivity-overlay {
position: fixed; position: fixed;
right: 15px; right: 15px;
@@ -673,6 +737,13 @@ ion-content.full-screen-content {
backdrop-filter: none !important; backdrop-filter: none !important;
} }
.landscape-audio-player {
--background: #ffffff !important;
background: #ffffff !important;
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.slim-controls .group { .slim-controls .group {
background: rgba(0, 0, 0, 0.05) !important; background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important; border: 1px solid rgba(0, 0, 0, 0.15) !important;
@@ -807,3 +878,34 @@ ion-content.full-screen-content {
} }
} }
} }
.black-screen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000000;
z-index: 99999;
cursor: pointer;
}
.landscape-playlist-btn {
writing-mode: vertical-rl;
text-transform: uppercase;
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 1px;
height: auto;
min-height: 75px;
width: 30px;
margin: 0;
--padding-start: 2px;
--padding-end: 2px;
color: var(--ion-color-secondary);
display: flex;
align-items: center;
justify-content: center;
transform: rotate(180deg);
}
+226 -20
View File
@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AlertController, ToastController, GestureController } from '@ionic/angular'; import { AlertController, ToastController, GestureController } from '@ionic/angular';
@@ -24,6 +24,24 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public canto = signal<Canto | null>(null); public canto = signal<Canto | null>(null);
public activeSongId = signal<string | null>(null); public activeSongId = signal<string | null>(null);
@HostListener('window:keydown', ['$event'])
handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
if (event.key === 'ArrowUp') {
this.prevSong();
event.preventDefault();
} else if (event.key === 'ArrowDown') {
this.nextSong();
event.preventDefault();
}
}
public isLandscapeActive = signal<boolean>(window.innerWidth > window.innerHeight);
/** true = show chords (accordi mode), false = text only */ /** true = show chords (accordi mode), false = text only */
public showChords = signal<boolean>(false); public showChords = signal<boolean>(false);
@@ -44,7 +62,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
// For local songs, if accordi is missing but testo has chords, use parseAccordi on testo // For local songs, if accordi is missing but testo has chords, use parseAccordi on testo
const hasChordsInText = !c.accordi && c.testo?.includes('['); const hasChordsInText = !c.accordi && c.testo?.includes('[');
if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) { // Force text-only mode in landscape
const activeShowChords = this.showChords() && !this.isLandscapeActive();
if ((activeShowChords && c.accordi) || (activeShowChords && hasChordsInText)) {
sections = this.lyricsParser.parseAccordi(c.accordi || c.testo); sections = this.lyricsParser.parseAccordi(c.accordi || c.testo);
} else { } else {
sections = this.lyricsParser.parseText(c.testo); sections = this.lyricsParser.parseText(c.testo);
@@ -68,7 +89,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
// Apply transposition if in chords mode // Apply transposition if in chords mode
if (this.showChords()) { if (activeShowChords) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount()); return this.lyricsParser.transposeSections(sections, this.transposeAmount());
} }
@@ -88,6 +109,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private readonly MAX_FONT = 5.0; private readonly MAX_FONT = 5.0;
private readonly FONT_STEP = 0.15; private readonly FONT_STEP = 0.15;
public minZoom = computed(() => this.isLandscapeActive() ? 2.0 : this.MIN_FONT);
public maxZoom = computed(() => this.isLandscapeActive() ? 3.0 : this.MAX_FONT);
private initialPinchDistance: number | null = null; private initialPinchDistance: number | null = null;
private initialFontSize: number = 1.0; private initialFontSize: number = 1.0;
@@ -104,7 +128,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
public router = inject(Router); public router = inject(Router);
public cantiService = inject(CantiService); public cantiService = inject(CantiService);
private lyricsParser = inject(LyricsParserService); public lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController); private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
public themeService = inject(ThemeService); public themeService = inject(ThemeService);
@@ -210,6 +234,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (scrollEl) { if (scrollEl) {
scrollEl.scrollTop = 0; scrollEl.scrollTop = 0;
} }
this.checkLandscapeZoom();
}, 100); }, 100);
} }
} }
@@ -269,9 +294,147 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.autoscrollSpeed.set(2); this.autoscrollSpeed.set(2);
} }
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
// Fullscreen control in landscape mode
effect(() => {
const isLandscape = this.isLandscapeActive();
if (isLandscape) {
this.enterFullscreen();
} else {
this.exitFullscreen();
}
});
}
public isBlackScreen = signal<boolean>(false);
@HostListener('window:resize', ['$event'])
onResize(event: any) {
this.isLandscapeActive.set(window.innerWidth > window.innerHeight);
this.checkLandscapeZoom();
}
private isLandscape(): boolean {
return window.innerWidth > window.innerHeight;
}
private checkAndLimitFontSize(targetFont: number): number {
const titleMain = this.el.nativeElement.querySelector('.title-main');
if (!titleMain) return targetFont;
const originalStyle = titleMain.style.fontSize;
let bestFont = targetFont;
// We want to find the largest font <= targetFont where the title stays on one line.
// Let's iterate down from targetFont to 0.6 in steps of 0.05.
for (let f = targetFont; f >= 0.6; f -= 0.05) {
titleMain.style.fontSize = `${f * 1.1}rem`;
// Force layout calculation
const height = titleMain.clientHeight;
// Get computed line height
const computedStyle = window.getComputedStyle(titleMain);
const lineHeightVal = computedStyle.lineHeight;
let lh = parseFloat(lineHeightVal);
// If line-height is 'normal', fallback to a reasonable estimate based on font-size
if (isNaN(lh) || lineHeightVal === 'normal') {
const fs = parseFloat(computedStyle.fontSize) || (f * 1.1 * 16);
lh = fs * 1.25;
}
// If the actual height is less than 1.5 * line-height, it fits on one line!
if (height <= lh * 1.5) {
bestFont = f;
break;
}
bestFont = f; // Fallback
}
// Restore original style
titleMain.style.fontSize = originalStyle;
return Math.max(0.6, parseFloat(bestFont.toFixed(2)));
}
public checkLandscapeZoom() {
if (this.isLandscape()) {
setTimeout(() => {
const container = this.el.nativeElement.querySelector('.lyrics-container');
if (!container) return;
const visibleHeight = container.clientHeight;
const lineElems = this.el.nativeElement.querySelectorAll('.lyric-line');
if (lineElems.length > 0 && visibleHeight > 0) {
let totalHeight = 0;
lineElems.forEach((el: any) => {
totalHeight += el.getBoundingClientRect().height;
});
const avgLineHeight = totalHeight / lineElems.length;
if (avgLineHeight > 0) {
const currentFont = this.fontSize();
const margin = 16; // 1rem in pixels
const targetLineHeight = Math.max(10, (visibleHeight / 3.1) - margin);
const avgLineHeightAtFont1 = avgLineHeight / currentFont;
const newFont = targetLineHeight / avgLineHeightAtFont1;
const targetFont = Math.max(2.0, Math.min(3.0, newFont));
let limitedFont = this.checkAndLimitFontSize(targetFont);
if (limitedFont < 2.0) {
limitedFont = 2.0;
}
this.fontSize.set(limitedFont);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
}
}
}, 150);
} else {
const limitedFont = this.checkAndLimitFontSize(1.0);
this.fontSize.set(limitedFont);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: limitedFont });
}
}
deactivateBlackScreen() {
this.isBlackScreen.set(false);
}
private async enterFullscreen() {
try {
const docEl = document.documentElement;
if (docEl.requestFullscreen) {
await docEl.requestFullscreen();
} else if ((docEl as any).webkitRequestFullscreen) {
await (docEl as any).webkitRequestFullscreen();
} else if ((docEl as any).msRequestFullscreen) {
await (docEl as any).msRequestFullscreen();
}
} catch (e) {
console.warn('[PlayerPage] Failed to enter fullscreen:', e);
}
}
private async exitFullscreen() {
try {
if (document.exitFullscreen) {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
} else if ((document as any).webkitExitFullscreen) {
if ((document as any).webkitFullscreenElement) {
await (document as any).webkitExitFullscreen();
}
} else if ((document as any).msExitFullscreen) {
if ((document as any).msFullscreenElement) {
await (document as any).msExitFullscreen();
}
}
} catch (e) {
console.warn('[PlayerPage] Failed to exit fullscreen:', e);
}
} }
ngAfterViewInit() { ngAfterViewInit() {
this.checkLandscapeZoom();
const gestureX = this.gestureCtrl.create({ const gestureX = this.gestureCtrl.create({
el: this.el.nativeElement, el: this.el.nativeElement,
direction: 'x', direction: 'x',
@@ -291,6 +454,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
}); });
gestureX.enable(); gestureX.enable();
} }
@@ -345,9 +509,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const currentDistance = this.getDistance(event.touches[0], event.touches[1]); const currentDistance = this.getDistance(event.touches[0], event.touches[1]);
const ratio = currentDistance / this.initialPinchDistance; const ratio = currentDistance / this.initialPinchDistance;
let newSize = this.initialFontSize * ratio; let newSize = this.initialFontSize * ratio;
newSize = Math.max(this.MIN_FONT, Math.min(this.MAX_FONT, newSize)); const minZ = this.minZoom();
if (Math.abs(newSize - this.fontSize()) > 0.01) { const maxZ = this.maxZoom();
this.fontSize.set(newSize); newSize = Math.max(minZ, Math.min(maxZ, newSize));
let limited = this.checkAndLimitFontSize(newSize);
if (this.isLandscape() && limited < 2.0) {
limited = 2.0;
}
if (Math.abs(limited - this.fontSize()) > 0.01) {
this.fontSize.set(limited);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
} }
} }
@@ -358,6 +528,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.updatePlaylistSettings(); this.updatePlaylistSettings();
} }
private getDistance(t1: Touch, t2: Touch): number { private getDistance(t1: Touch, t2: Touch): number {
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2)); return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
} }
@@ -366,6 +537,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.showChords.update(v => !v); this.showChords.update(v => !v);
this.channel.postMessage({ type: 'SYNC_CHORDS', showChords: this.showChords() }); this.channel.postMessage({ type: 'SYNC_CHORDS', showChords: this.showChords() });
this.transposeAmount.set(0); this.transposeAmount.set(0);
this.checkLandscapeZoom();
} }
private updatePlaylistSettings() { private updatePlaylistSettings() {
@@ -401,16 +573,26 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
zoomIn() { zoomIn() {
if (this.fontSize() < this.MAX_FONT) { const maxZ = this.maxZoom();
this.fontSize.update(v => Math.min(v + this.FONT_STEP, this.MAX_FONT)); if (this.fontSize() < maxZ) {
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); const target = Math.min(this.fontSize() + this.FONT_STEP, maxZ);
this.updatePlaylistSettings(); let limited = this.checkAndLimitFontSize(target);
if (this.isLandscape() && limited < 2.0) {
limited = 2.0;
}
if (limited !== this.fontSize()) {
this.fontSize.set(limited);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings();
}
} }
} }
zoomOut() { zoomOut() {
if (this.fontSize() > this.MIN_FONT) { const minZ = this.minZoom();
this.fontSize.update(v => Math.max(v - this.FONT_STEP, this.MIN_FONT)); if (this.fontSize() > minZ) {
const target = Math.max(this.fontSize() - this.FONT_STEP, minZ);
this.fontSize.set(target);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings(); this.updatePlaylistSettings();
} }
@@ -442,7 +624,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.currentLineIndex.set(0); this.currentLineIndex.set(0);
this.youtubePlayerService.seekTo(0); this.youtubePlayerService.seekTo(0);
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 }); this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
this.lastScrollBlock.set('center'); this.lastScrollBlock.set('start');
setTimeout(() => {
const scrollEl = this.el.nativeElement.querySelector('.lyrics-container');
if (scrollEl) {
scrollEl.scrollTop = 0;
}
}, 100);
} }
public calculatePageStepSize(): number { public calculatePageStepSize(): number {
@@ -600,13 +788,16 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
if (foundNextIdx !== -1) { if (foundNextIdx !== -1) {
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx); // Overlap by 1 line to ensure the last visible line is repeated at the top of the next page
return foundNextIdx; const targetIdx = Math.max(currentIdx + 1, foundNextIdx - 1);
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Nuova pagina inizierà con overlap a:', targetIdx);
return targetIdx;
} }
// Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard // Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard con 1 riga di overlap
const step = this.calculatePageStepSize(); const step = this.calculatePageStepSize();
return Math.min(totalLines - 1, currentIdx + step); const targetIdx = Math.max(currentIdx + 1, currentIdx + step - 1);
return Math.min(totalLines - 1, targetIdx);
} catch (e) { } catch (e) {
console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e); console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e);
return this.currentLineIndex() + 1; return this.currentLineIndex() + 1;
@@ -614,6 +805,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
next(isAutomatic: boolean = false, isVisual: boolean = false) { next(isAutomatic: boolean = false, isVisual: boolean = false) {
if (this.isBlackScreen()) {
this.deactivateBlackScreen();
return;
}
this.lastAdvanceTimestamp = Date.now(); this.lastAdvanceTimestamp = Date.now();
const totalLines = this.getTotalLines(); const totalLines = this.getTotalLines();
@@ -638,7 +833,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
nextIdx = this.calculateNextPageStartIndex(); nextIdx = this.calculateNextPageStartIndex();
} }
this.lastScrollBlock.set('center'); this.lastScrollBlock.set('center');
} else if (this.settingsService.karaokePageScrollMode() || isVisual) { } else if ((this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive()) {
// Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte // Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte
nextIdx = this.calculateNextPageStartIndex(); nextIdx = this.calculateNextPageStartIndex();
this.lastScrollBlock.set('start'); this.lastScrollBlock.set('start');
@@ -656,11 +851,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
// Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti // Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti
prev(isVisual: boolean = false) { prev(isVisual: boolean = false) {
if (this.isBlackScreen()) {
this.deactivateBlackScreen();
return;
}
this.lastAdvanceTimestamp = Date.now(); this.lastAdvanceTimestamp = Date.now();
const prevIdx = this.currentLineIndex(); const prevIdx = this.currentLineIndex();
if (prevIdx > 0) { if (prevIdx > 0) {
const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual; const isPageMode = (this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive();
const stepSize = isPageMode ? this.calculatePageStepSize() : 1; const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
const nextIdx = Math.max(0, prevIdx - stepSize); const nextIdx = Math.max(0, prevIdx - stepSize);
@@ -690,6 +889,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const index = list.indexOf(currentId); const index = list.indexOf(currentId);
if (index >= 0 && index < list.length - 1) { if (index >= 0 && index < list.length - 1) {
const nextId = list[index + 1]; const nextId = list[index + 1];
if (this.isLandscapeActive()) {
this.isBlackScreen.set(true);
}
this.router.navigate(['/player'], { queryParams: { id: nextId } }); this.router.navigate(['/player'], { queryParams: { id: nextId } });
} else { } else {
this.playlistService.autoPlayPlaylist.set(false); this.playlistService.autoPlayPlaylist.set(false);
@@ -708,6 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const index = list.indexOf(currentId); const index = list.indexOf(currentId);
if (index > 0) { if (index > 0) {
const prevId = list[index - 1]; const prevId = list[index - 1];
if (this.isLandscapeActive()) {
this.isBlackScreen.set(true);
}
this.router.navigate(['/player'], { queryParams: { id: prevId } }); this.router.navigate(['/player'], { queryParams: { id: prevId } });
} }
} }
@@ -949,6 +1154,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
ngOnDestroy() { ngOnDestroy() {
this.exitFullscreen();
this.stopAutoscroll(); this.stopAutoscroll();
this.logPreviousSongTime(); this.logPreviousSongTime();
this.stopCameraNavigation(); this.stopCameraNavigation();
@@ -31,107 +31,159 @@
</div> </div>
</div> </div>
<ion-list lines="none" class="input-list"> <div class="editor-preview-split">
<ion-item class="custom-input-item"> <!-- Left Column: Inputs & Editor -->
<ion-label position="stacked">Titolo del Canto</ion-label> <div class="editor-column">
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input> <ion-list lines="none" class="input-list">
</ion-item> <ion-item class="custom-input-item">
<ion-label position="stacked">Titolo del Canto</ion-label>
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
</ion-item>
<div class="category-selectors"> <div class="category-selectors">
<ion-item class="custom-input-item select-item"> <ion-item class="custom-input-item select-item">
<ion-label position="stacked">Momento Liturgico</ion-label> <ion-label position="stacked">Momento Liturgico</ion-label>
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover"> <ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id"> <ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
{{ lit.tag_name }} {{ lit.tag_name }}
</ion-select-option> </ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>
<ion-item class="custom-input-item select-item"> <ion-item class="custom-input-item select-item">
<ion-label position="stacked">Periodo / Tema</ion-label> <ion-label position="stacked">Periodo / Tema</ion-label>
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover"> <ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id"> <ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
{{ tem.tag_name }} {{ tem.tag_name }}
</ion-select-option> </ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>
</div>
<!-- TOOLBARS -->
<div class="toolbar-section">
<div class="horizontal-toolbar">
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
{{ tag.label }}
</ion-button>
</div>
</div>
<!-- Main Chords Selector Toolbar -->
<div class="toolbar-section">
<div class="horizontal-toolbar main-chords-toolbar">
<ion-button
*ngFor="let group of groupedChords"
size="small"
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
(click)="selectRoot(group.root)">
{{ group.root }}
</ion-button>
</div>
</div>
<!-- Variations Toolbar (only visible if a root chord is selected) -->
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
<div class="horizontal-toolbar variations-toolbar">
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
<ion-button
size="small"
*ngFor="let chord of getVariations()"
(click)="insertChord(chord)">
{{ chord }}
</ion-button>
</div>
</div>
<!-- EDITOR AREA -->
<div class="editor-wrapper" [class.hc]="isHighContrast">
<div class="editor-header">
<div class="editor-title-group">
<span>Editor Testo</span>
</div> </div>
<div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0"> <!-- TOOLBARS -->
<ion-icon name="undo-outline"></ion-icon> <div class="toolbar-section">
</ion-button> <div class="horizontal-toolbar">
<ion-button fill="clear" size="small" (click)="takePhoto()"> <ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
<ion-icon name="camera-outline"></ion-icon> {{ tag.label }}
</ion-button>
</div>
</div>
<!-- Main Chords Selector Toolbar -->
<div class="toolbar-section">
<div class="horizontal-toolbar main-chords-toolbar">
<ion-button
*ngFor="let group of groupedChords"
size="small"
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
(click)="selectRoot(group.root)">
{{ group.root }}
</ion-button>
</div>
</div>
<!-- Variations Toolbar (only visible if a root chord is selected) -->
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
<div class="horizontal-toolbar variations-toolbar">
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
<ion-button
size="small"
*ngFor="let chord of getVariations()"
(click)="insertChord(chord)">
{{ chord }}
</ion-button>
</div>
</div>
<!-- EDITOR AREA -->
<div class="editor-wrapper" [class.hc]="isHighContrast">
<div class="editor-header">
<div class="editor-title-group">
<span>Editor Testo</span>
</div>
<div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
<ion-icon name="undo-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="takePhoto()">
<ion-icon name="camera-outline"></ion-icon>
</ion-button>
</div>
</div>
<ion-item class="custom-input-item textarea-item">
<ion-textarea
#contentTextarea
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</div>
<ion-item class="custom-input-item">
<ion-label position="stacked">Autore / Link YouTube</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
</ion-item>
</ion-list>
<div class="action-buttons">
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
<ion-icon slot="start" name="save-outline"></ion-icon>
Salva nei Miei Canti
</ion-button>
</div>
</div>
<!-- Right Column: Active Preview Pane -->
<div class="preview-column">
<div class="preview-card" [class.hc]="isHighContrast">
<div class="preview-header">
<span class="preview-title">Anteprima Attiva</span>
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn">
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
</ion-button> </ion-button>
</div> </div>
<div class="preview-body">
<div class="preview-song-header">
<h2 class="preview-song-title">{{ title || 'Titolo del Canto' }}</h2>
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
</div>
<div class="preview-lyrics-container">
<div *ngFor="let section of parsedSections"
class="preview-section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="preview-section-label preview-verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines" class="preview-lyric-line">
<!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="preview-chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="preview-chord">{{ seg.chord }}</span>
<span class="preview-seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
</div>
</div>
<div class="preview-empty" *ngIf="!content">
Il testo formattato apparirà qui mentre scrivi...
</div>
</div>
</div>
</div> </div>
<ion-item class="custom-input-item textarea-item">
<ion-textarea
#contentTextarea
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</div> </div>
<ion-item class="custom-input-item">
<ion-label position="stacked">Autore / Link YouTube</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
</ion-item>
</ion-list>
<div class="action-buttons">
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
<ion-icon slot="start" name="save-outline"></ion-icon>
Salva nei Miei Canti
</ion-button>
</div> </div>
</div> </div>
@@ -113,7 +113,8 @@ body.high-contrast :host ::ng-deep {
} }
.propose-container { .propose-container {
max-width: 800px; max-width: 1400px;
width: 100%;
margin: 0 auto; margin: 0 auto;
} }
@@ -470,3 +471,233 @@ body.high-contrast :host ::ng-deep {
} }
} }
} }
/* RESPONSIVE LAYOUT & ACTIVE PREVIEW PANE */
.editor-preview-split {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
align-items: flex-start;
@media (min-width: 992px) {
grid-template-columns: 1fr 1fr;
}
}
.preview-column {
@media (min-width: 992px) {
position: sticky;
top: 16px;
}
}
.preview-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
.preview-header {
background: rgba(255, 255, 255, 0.04);
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.preview-title {
font-size: 11px;
font-weight: 800;
color: var(--ion-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-toggle-btn {
--color: rgba(255, 255, 255, 0.7);
margin: 0;
font-size: 11px;
font-weight: 600;
ion-icon {
font-size: 14px;
}
}
}
.preview-body {
padding: 24px;
min-height: 400px;
background: rgba(0, 0, 0, 0.2);
.preview-song-header {
margin-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 14px;
.preview-song-title {
font-size: 1.6rem;
font-weight: 700;
color: #ffffff;
margin: 0 0 6px 0;
font-family: 'Outfit', sans-serif;
}
.preview-song-author {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
font-weight: 500;
}
}
.preview-lyrics-container {
font-size: 1.1rem;
font-family: 'Outfit', sans-serif;
overflow-y: auto;
max-height: 60vh;
padding-right: 8px;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.preview-section {
margin-bottom: 1.8rem;
position: relative;
&.chorus {
background: rgba(var(--ion-color-secondary-rgb), 0.03);
border-left: 3px solid var(--ion-color-secondary);
padding-left: 1rem;
margin-left: -1rem;
border-radius: 0 8px 8px 0;
}
.preview-section-label {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 700;
color: var(--ion-color-secondary);
margin-bottom: 0.4rem;
opacity: 0.7;
letter-spacing: 1px;
}
}
.preview-lyric-line {
margin-bottom: 0.8rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
min-height: 1.5em;
}
.preview-chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
margin-right: 0.25em;
&.contiguous-next {
margin-right: 0 !important;
}
.preview-chord {
font-size: 0.78em;
font-weight: 700;
color: var(--ion-color-secondary);
height: 1.25em;
margin-bottom: -0.2em;
}
.preview-seg-text {
white-space: pre;
&::after {
content: '\200b';
}
}
}
.preview-empty {
color: rgba(255, 255, 255, 0.3);
text-align: center;
padding-top: 80px;
font-style: italic;
font-size: 0.95rem;
}
}
}
/* High Contrast mode overrides */
&.hc {
background: #ffffff;
border: 2px solid #000000;
box-shadow: none;
.preview-header {
background: #f0f0f0;
border-bottom: 2px solid #000000;
.preview-title {
color: #000000;
}
.preview-toggle-btn {
--color: #000000;
font-weight: 700;
}
}
.preview-body {
background: #ffffff;
.preview-song-header {
border-bottom: 2px solid #000000;
.preview-song-title {
color: #000000;
}
.preview-song-author {
color: #333333;
}
}
.preview-lyrics-container {
.preview-section {
&.chorus {
background: #f5f5f5;
border-left: 3px solid #000000;
}
.preview-section-label {
color: #000000;
}
}
.preview-lyric-line {
color: #000000;
}
.preview-chord-segment {
.preview-chord {
color: #000000;
text-decoration: underline;
font-weight: 800;
}
}
.preview-empty {
color: #666666;
}
}
}
}
}
@@ -8,6 +8,7 @@ import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service'; import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service'; import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule, Router } from '@angular/router'; import { ActivatedRoute, RouterModule, Router } from '@angular/router';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
@Component({ @Component({
selector: 'app-propose-canto', selector: 'app-propose-canto',
@@ -27,6 +28,17 @@ export class ProposeCantoPage implements OnInit {
public themeService = inject(ThemeService); public themeService = inject(ThemeService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
public lyricsParser = inject(LyricsParserService);
showChordsPreview: boolean = true;
get parsedSections(): ParsedSection[] {
return this.lyricsParser.parseAccordi(this.content);
}
toggleChordsPreview() {
this.showChordsPreview = !this.showChordsPreview;
}
title: string = ''; title: string = '';
author: string = ''; author: string = '';
+1 -8
View File
@@ -62,14 +62,7 @@
</ion-label> </ion-label>
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle> <ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
</ion-item> </ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="expand-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Nascondi Barre (Player)</h2>
<p class="settings-item-subtitle">Modalità immersiva nel dettaglio canto</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.fullscreenMode()" (ionChange)="settingsService.toggleFullscreenMode()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon> <ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font"> <ion-label class="outfit-font">
+32 -19
View File
@@ -347,15 +347,33 @@ export class SettingsPage {
await registration.update(); await registration.update();
} }
let sub: any = null;
let readyPromise: Promise<void> | undefined = undefined;
if (this.swUpdate.isEnabled) {
readyPromise = new Promise<void>((resolve) => {
sub = this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
resolve();
});
});
}
// Layer 2: Ask Angular SW to check // Layer 2: Ask Angular SW to check
if (this.swUpdate.isEnabled) { if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate(); const swFoundUpdate = await this.swUpdate.checkForUpdate();
if (swFoundUpdate) { if (swFoundUpdate) {
await this.applyUpdateAndReload(); await this.applyUpdateAndReload(readyPromise, sub);
return true; return true;
} }
} }
if (sub) sub.unsubscribe();
// Layer 3: Fallback — check version.json // Layer 3: Fallback — check version.json
const versionMismatch = await this.checkVersionJson(); const versionMismatch = await this.checkVersionJson();
if (versionMismatch) { if (versionMismatch) {
@@ -377,7 +395,7 @@ export class SettingsPage {
} }
} }
private async applyUpdateAndReload() { private async applyUpdateAndReload(readyPromise?: Promise<void>, subscription?: any) {
const overlay = showFullscreenUpdateOverlay(); const overlay = showFullscreenUpdateOverlay();
let activated = false; let activated = false;
@@ -397,24 +415,19 @@ export class SettingsPage {
}, 600); }, 600);
}; };
// Listen for VERSION_READY + activate + reload if (readyPromise) {
if (this.swUpdate.isEnabled) { Promise.race([
this.swUpdate.versionUpdates readyPromise,
.pipe( new Promise((resolve) => setTimeout(resolve, 25000))
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), ]).then(() => {
first() if (subscription) subscription.unsubscribe();
) activateAndReload();
.subscribe(() => { });
console.log('[PWA-Update] Settings: version ready, activating...'); } else {
activateAndReload(); setTimeout(() => {
}); activateAndReload();
}, 1000);
} }
// Safety timeout: reload after 6s regardless
setTimeout(() => {
console.log('[PWA-Update] Settings: safety timeout reached, activating...');
activateAndReload();
}, 6000);
} }
private async checkVersionJson(): Promise<boolean> { private async checkVersionJson(): Promise<boolean> {
+30 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, signal, inject } from '@angular/core'; import { Injectable, signal, inject } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http'; import { HttpClient, HttpEventType } from '@angular/common/http';
import { Storage } from '@ionic/storage-angular'; import { Storage } from '@ionic/storage-angular';
import { SettingsService } from './settings.service';
export interface Canto { export interface Canto {
id: string; id: string;
@@ -34,6 +35,7 @@ export interface CantoEseguito {
export class CantiService { export class CantiService {
private http = inject(HttpClient); private http = inject(HttpClient);
private storage = inject(Storage); private storage = inject(Storage);
private settingsService = inject(SettingsService);
private _storage: Storage | null = null; private _storage: Storage | null = null;
public canti = signal<Canto[]>([]); public canti = signal<Canto[]>([]);
@@ -58,6 +60,20 @@ export class CantiService {
await this.loadFromStorage(); await this.loadFromStorage();
if (this.canti() && this.canti().length > 0) { if (this.canti() && this.canti().length > 0) {
this.firstLoadCompleted.set(true); this.firstLoadCompleted.set(true);
if (this.settingsService.isVersionCheckComplete() && (window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
} else {
// First boot or data cleared: show setup loader immediately
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Setup in corso',
phase: 'Fase: Setup',
desc: 'Configurazione iniziale e caricamento canti...',
percent: 0
});
}
} }
this.refresh(); this.refresh();
} }
@@ -85,10 +101,16 @@ export class CantiService {
}).subscribe({ }).subscribe({
next: async (event: any) => { next: async (event: any) => {
if (event.type === HttpEventType.DownloadProgress) { if (event.type === HttpEventType.DownloadProgress) {
let pct = 0;
if (event.total) { if (event.total) {
this.progress.set(Math.round((event.loaded / event.total) * 100)); pct = Math.round((event.loaded / event.total) * 100);
this.progress.set(pct);
} else { } else {
this.progress.update(p => p < 90 ? p + 5 : p); this.progress.update(p => p < 90 ? p + 5 : p);
pct = this.progress();
}
if (!this.firstLoadCompleted() && (window as any).PwaLoader) {
(window as any).PwaLoader.update({ percent: pct });
} }
} else if (event.type === HttpEventType.Response) { } else if (event.type === HttpEventType.Response) {
const response = event.body; const response = event.body;
@@ -137,12 +159,19 @@ export class CantiService {
this.progress.set(100); this.progress.set(100);
this.loading.set(false); this.loading.set(false);
this.firstLoadCompleted.set(true); this.firstLoadCompleted.set(true);
if (this.settingsService.isVersionCheckComplete() && (window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
} }
}, },
error: (error) => { error: (error) => {
console.error('Failed to fetch canti', error); console.error('Failed to fetch canti', error);
this.loading.set(false); this.loading.set(false);
this.firstLoadCompleted.set(true); this.firstLoadCompleted.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
} }
}); });
} }
+44 -5
View File
@@ -149,7 +149,7 @@ export class LyricsParserService {
} }
// Add the chord as a new segment (text will be filled by next text chunk) // Add the chord as a new segment (text will be filled by next text chunk)
segments.push({ chord: match[1], text: '' }); segments.push({ chord: this.transposeChord(match[1], 0), text: '' });
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
@@ -197,7 +197,7 @@ export class LyricsParserService {
const upperChord = chord.toUpperCase(); const upperChord = chord.toUpperCase();
for (const r of possibleRoots) { for (const r of possibleRoots) {
if (upperChord.startsWith(r)) { if (upperChord.startsWith(r.toUpperCase())) {
root = r; root = r;
suffix = chord.substring(r.length); suffix = chord.substring(r.length);
break; break;
@@ -206,8 +206,11 @@ export class LyricsParserService {
if (!root) return chord; if (!root) return chord;
let index = this.scale.indexOf(root); const upperRoot = root.toUpperCase();
if (index === -1) index = this.flatScale.indexOf(root); let index = this.scale.indexOf(upperRoot);
if (index === -1) {
index = this.flatScale.findIndex(n => n.toUpperCase() === upperRoot);
}
if (index === -1) return chord; if (index === -1) return chord;
let newIndex = (index + semitones) % 12; let newIndex = (index + semitones) % 12;
@@ -231,7 +234,12 @@ export class LyricsParserService {
} }
} }
const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex]; let newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
if (newRoot === 'LA#') {
newRoot = 'SIb';
} else if (newRoot === 'RE#') {
newRoot = 'MIb';
}
return newRoot + suffix; return newRoot + suffix;
} }
@@ -251,4 +259,35 @@ export class LyricsParserService {
})) }))
})); }));
} }
/**
* Helper to check if the segment at the given index is contiguous with the next segment.
* This means the text is split within a word (e.g. by a chord tag like "rima[RE]ne").
*/
isContiguousNext(segments: ChordSegment[], index: number): boolean {
if (!segments || index >= segments.length - 1) return false;
// Find the next segment with non-empty text
let nextWithText: ChordSegment | null = null;
for (let i = index + 1; i < segments.length; i++) {
if (segments[i].text && segments[i].text.length > 0) {
nextWithText = segments[i];
break;
}
}
const currentText = segments[index].text || '';
if (!currentText) {
// If current segment has no text, it should have no margin-right to not add extra spacing
return true;
}
if (!nextWithText) return false;
const endsWithNonSpace = !/\s$/.test(currentText);
const startsWithNonSpace = !/^\s/.test(nextWithText.text);
return endsWithNonSpace && startsWithNonSpace;
}
} }
+1
View File
@@ -64,6 +64,7 @@ export class SettingsService {
public isStandalone = signal<boolean>(false); public isStandalone = signal<boolean>(false);
public isIos = signal<boolean>(false); public isIos = signal<boolean>(false);
public isAndroid = signal<boolean>(false); public isAndroid = signal<boolean>(false);
public isVersionCheckComplete = signal<boolean>(false);
constructor() { constructor() {
// Gestione/Generazione ID utente univoco // Gestione/Generazione ID utente univoco
@@ -22,12 +22,6 @@ export class YoutubePlayerService {
if (!this.connectivityService.isOnline()) { if (!this.connectivityService.isOnline()) {
return false; return false;
} }
// Check if iPhone/iPad/iPod
const userAgent = window.navigator.userAgent.toLowerCase();
const isIOS = /iphone|ipad|ipod/.test(userAgent);
if (isIOS) {
return false;
}
return true; return true;
}); });
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.06.12.1712'; export const VERSION = '2026.06.14.1936';
+145 -67
View File
@@ -34,77 +34,155 @@
</head> </head>
<body> <body>
<app-root> <div id="pwa-boot-loader" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif; transition: opacity 0.5s ease;">
<div id="pwa-boot-loader" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: #121212; color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif;"> <div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;"> <div style="margin-bottom: 25px; display: inline-block;">
<div style="margin-bottom: 25px; display: inline-block;"> <img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div> </div>
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div> </div>
<script> </div>
(function() {
const isUpdate = window.location.search.includes('update');
const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc');
const phaseEl = document.getElementById('pwa-boot-phase');
// Detect if it is the first installation (no active service worker controlling the page yet) <app-root></app-root>
const isFirstInstall = 'serviceWorker' in navigator && !navigator.serviceWorker.controller;
if (isUpdate) {
if (titleEl) titleEl.textContent = 'Aggiornamento completato';
if (descEl) descEl.textContent = 'Ottimizzazione e avvio della nuova versione...';
if (phaseEl) phaseEl.textContent = 'Fase: Avvio';
} else if (isFirstInstall) {
if (titleEl) titleEl.textContent = 'Download in corso';
if (descEl) descEl.textContent = 'Scaricamento dei componenti dell\'applicazione...';
if (phaseEl) phaseEl.textContent = 'Fase: Download';
}
const versionEl = document.getElementById('pwa-boot-version');
fetch('/version.json?cb=' + Date.now())
.then(res => {
if (res.ok) return res.json();
throw new Error('Fallback');
})
.then(data => {
if (data && data.version && versionEl) {
versionEl.textContent = 'Versione ' + data.version;
}
})
.catch(e => {
if (versionEl) {
versionEl.textContent = '';
}
});
let percent = 0;
const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent');
const interval = setInterval(() => {
if (percent < 98) {
percent += Math.floor(Math.random() * 8) + 3;
if (percent > 98) percent = 98;
if (bar) bar.style.width = percent + '%';
if (pctText) pctText.textContent = percent + '%';
} else {
clearInterval(interval);
}
}, 80);
})();
</script>
</app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <noscript>Please enable JavaScript to continue using this application.</noscript>
<script>
(function() {
// 1. Loader API definition
window.PwaLoader = {
update: function(options) {
const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc');
const phaseEl = document.getElementById('pwa-boot-phase');
const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent');
const versionEl = document.getElementById('pwa-boot-version');
if (options.title && titleEl) titleEl.textContent = options.title;
if (options.desc && descEl) descEl.textContent = options.desc;
if (options.phase && phaseEl) phaseEl.textContent = options.phase;
if (options.version && versionEl) versionEl.textContent = options.version;
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 + '%';
}
},
hide: function() {
const loader = document.getElementById('pwa-boot-loader');
if (loader) {
loader.style.opacity = '0';
setTimeout(() => {
if (loader.parentNode) {
loader.parentNode.removeChild(loader);
}
}, 500);
}
},
show: function() {
let loader = document.getElementById('pwa-boot-loader');
if (!loader) {
loader = document.createElement('div');
loader.id = 'pwa-boot-loader';
loader.style.position = 'fixed';
loader.style.top = '0';
loader.style.left = '0';
loader.style.width = '100vw';
loader.style.height = '100vh';
loader.style.background = 'radial-gradient(circle at top left, #2d3436 0%, #121212 100%)';
loader.style.color = '#ffffff';
loader.style.display = 'flex';
loader.style.flexDirection = 'column';
loader.style.justifyContent = 'center';
loader.style.alignItems = 'center';
loader.style.zIndex = '99999';
loader.style.fontFamily = "'Outfit', sans-serif";
loader.style.transition = 'opacity 0.5s ease';
loader.style.opacity = '0';
loader.innerHTML = `
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div>
`;
document.body.appendChild(loader);
loader.offsetHeight; // Force reflow
loader.style.opacity = '1';
}
}
};
// 2. Setup initial labels
const isUpdate = window.location.search.includes('update');
const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc');
const phaseEl = document.getElementById('pwa-boot-phase');
const isFirstInstall = 'serviceWorker' in navigator && !navigator.serviceWorker.controller;
if (isUpdate) {
if (titleEl) titleEl.textContent = 'Aggiornamento completato';
if (descEl) descEl.textContent = 'Ottimizzazione e avvio della nuova versione...';
if (phaseEl) phaseEl.textContent = 'Fase: Avvio';
} else if (isFirstInstall) {
if (titleEl) titleEl.textContent = 'Download in corso';
if (descEl) descEl.textContent = 'Scaricamento dei componenti dell\'applicazione...';
if (phaseEl) phaseEl.textContent = 'Fase: Download';
}
const versionEl = document.getElementById('pwa-boot-version');
fetch('/version.json?cb=' + Date.now())
.then(res => {
if (res.ok) return res.json();
throw new Error('Fallback');
})
.then(data => {
if (data && data.version && versionEl) {
versionEl.textContent = 'Versione ' + data.version;
}
})
.catch(e => {
if (versionEl) {
versionEl.textContent = '';
}
});
// 3. Start default progressive animation
let percent = 0;
const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent');
const interval = setInterval(() => {
// Only auto-advance if we are in the initial boot/install phase and not hijacked by Angular
if (percent < 90) {
percent += Math.floor(Math.random() * 8) + 3;
if (percent > 90) percent = 90;
if (bar) bar.style.width = percent + '%';
if (pctText) pctText.textContent = percent + '%';
} else {
clearInterval(interval);
}
}, 80);
})();
</script>
</body> </body>
</html> </html>