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:
+91
-12
@@ -2,21 +2,100 @@
|
||||
<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>
|
||||
|
||||
<!-- Unified Setup Loader Overlay -->
|
||||
<div *ngIf="!cantiService.firstLoadCompleted()"
|
||||
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%;">
|
||||
<!-- PWA Redirect Overlay -->
|
||||
<div *ngIf="showRedirectOverlay()"
|
||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||
<div style="margin-bottom: 25px; display: inline-block;">
|
||||
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.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>
|
||||
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; -webkit-font-smoothing: antialiased;">Setup 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>
|
||||
<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>
|
||||
<div style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px;">Fase: Setup</div>
|
||||
<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>
|
||||
<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: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
|
||||
Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
|
||||
</p>
|
||||
|
||||
<!-- 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 style="font-size: 1.2rem; font-weight: 700; color: #e67e22; -webkit-font-smoothing: antialiased;">{{ cantiService.progress() }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
+258
-93
@@ -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 { CantiService } from './services/canti.service';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { VERSION } from './version';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -14,10 +17,16 @@ import { ToastController } from '@ionic/angular';
|
||||
export class AppComponent implements OnInit {
|
||||
private themeService = inject(ThemeService); // Ensures theme is initialized at boot
|
||||
public cantiService = inject(CantiService);
|
||||
public settingsService = inject(SettingsService);
|
||||
public version = VERSION;
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private toastCtrl = inject(ToastController);
|
||||
private swUpdate = inject(SwUpdate);
|
||||
|
||||
public showRedirectOverlay = signal<boolean>(false);
|
||||
public showInstallOverlay = signal<boolean>(false);
|
||||
public protocolLink = '';
|
||||
|
||||
constructor() {
|
||||
// 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.
|
||||
}
|
||||
|
||||
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 => {
|
||||
const protocolUrl = params['url'];
|
||||
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||
@@ -55,92 +85,234 @@ export class AppComponent implements OnInit {
|
||||
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() {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
|
||||
if (isStandalone) {
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have sharing/navigation query parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const hasSharing = urlParams.has('import') || urlParams.has('playlist-uid') || urlParams.has('restore-uid') || urlParams.has('id');
|
||||
|
||||
if (hasSharing) {
|
||||
let isInstalled = false;
|
||||
if ('getInstalledRelatedApps' in navigator) {
|
||||
try {
|
||||
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
||||
isInstalled = relatedApps.length > 0;
|
||||
} catch (e) {
|
||||
console.warn('Failed to check installed apps:', e);
|
||||
const search = window.location.search;
|
||||
const path = window.location.pathname;
|
||||
this.protocolLink = `web+canti://open${path}${search}`;
|
||||
|
||||
// Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo
|
||||
let isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
||||
if (!isInstalled && 'getInstalledRelatedApps' in navigator) {
|
||||
try {
|
||||
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
||||
isInstalled = relatedApps.length > 0;
|
||||
if (isInstalled) {
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
console.warn('Failed to check installed apps:', e);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pwa-update-overlay';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
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);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Download aggiornamento',
|
||||
phase: 'Fase: Download',
|
||||
desc: 'Scaricamento della nuova versione...',
|
||||
percent: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch remote version to display the version being downloaded
|
||||
fetch(`/version.json?cb=${Date.now()}`)
|
||||
@@ -149,38 +321,31 @@ export function showFullscreenUpdateOverlay() {
|
||||
throw new Error('Fallback');
|
||||
})
|
||||
.then(data => {
|
||||
const versionEl = document.getElementById('pwa-update-version');
|
||||
if (data && data.version && versionEl) {
|
||||
versionEl.textContent = 'Versione ' + data.version;
|
||||
if (data && data.version && (window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({
|
||||
version: 'Versione ' + data.version
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const versionEl = document.getElementById('pwa-update-version');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = '';
|
||||
}
|
||||
});
|
||||
.catch(() => {});
|
||||
|
||||
let percent = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (percent < 95) {
|
||||
percent += Math.floor(Math.random() * 5) + 2;
|
||||
if (percent > 95) percent = 95;
|
||||
updatePercent(percent);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({ percent });
|
||||
}
|
||||
}
|
||||
}, 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 {
|
||||
finish: () => {
|
||||
clearInterval(interval);
|
||||
updatePercent(100);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({ percent: 100 });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+24
-57
@@ -19,9 +19,6 @@
|
||||
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||
</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-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -48,6 +45,19 @@
|
||||
{{ filteredCanti().length }}
|
||||
</div>
|
||||
<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()">
|
||||
<ion-button
|
||||
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
||||
@@ -116,19 +126,6 @@
|
||||
</span>
|
||||
</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
|
||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
@@ -186,8 +183,16 @@
|
||||
<div
|
||||
class="momento-chip glass"
|
||||
[class.active]="showNonValidati()"
|
||||
(click)="toggleNonValidati()">
|
||||
Non Validati
|
||||
(click)="toggleNonValidati()"
|
||||
style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<span>Non Validati</span>
|
||||
<ion-icon
|
||||
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
|
||||
name="add-outline"
|
||||
(click)="$event.stopPropagation()"
|
||||
routerLink="/propose-canto"
|
||||
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>
|
||||
</ng-container>
|
||||
|
||||
@@ -213,28 +218,6 @@
|
||||
(click)="onInteraction()">
|
||||
|
||||
<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 class="loading-wrapper">
|
||||
@@ -440,20 +423,4 @@
|
||||
</ion-toolbar>
|
||||
</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
@@ -43,9 +43,6 @@ export class HomePage implements OnDestroy {
|
||||
public isAddingSongs = signal<boolean>(false);
|
||||
public reorderList = signal<any[]>([]);
|
||||
public limit = signal<number>(30);
|
||||
|
||||
public showAndroidBanner = signal<boolean>(false);
|
||||
public showIosTooltip = signal<boolean>(false);
|
||||
public hasUpdateAvailable = signal<boolean>(false);
|
||||
|
||||
public fontSize = signal<number>(1.0);
|
||||
@@ -77,6 +74,7 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
private firstInteraction = true;
|
||||
private updatePollInterval: any = null;
|
||||
private queryParamsSubscription: any = null;
|
||||
|
||||
async checkForAppUpdate(event?: Event) {
|
||||
if (event) event.stopPropagation();
|
||||
@@ -117,14 +115,32 @@ export class HomePage implements OnDestroy {
|
||||
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) {
|
||||
const swFoundUpdate = await this.swUpdate.checkForUpdate();
|
||||
if (swFoundUpdate) {
|
||||
await this.applyUpdateAndReload();
|
||||
await this.applyUpdateAndReload(readyPromise, sub);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sub) sub.unsubscribe();
|
||||
|
||||
const versionMismatch = await this.checkVersionJson();
|
||||
if (versionMismatch) {
|
||||
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();
|
||||
|
||||
let activated = false;
|
||||
@@ -164,20 +180,19 @@ export class HomePage implements OnDestroy {
|
||||
}, 600);
|
||||
};
|
||||
|
||||
if (this.swUpdate.isEnabled) {
|
||||
this.swUpdate.versionUpdates
|
||||
.pipe(
|
||||
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
|
||||
first()
|
||||
)
|
||||
.subscribe(() => {
|
||||
activateAndReload();
|
||||
});
|
||||
if (readyPromise) {
|
||||
Promise.race([
|
||||
readyPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 25000))
|
||||
]).then(() => {
|
||||
if (subscription) subscription.unsubscribe();
|
||||
activateAndReload();
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
activateAndReload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
activateAndReload();
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
private async checkVersionJson(): Promise<boolean> {
|
||||
@@ -429,21 +444,7 @@ export class HomePage implements OnDestroy {
|
||||
this.checkUpdateStatus();
|
||||
}, 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
|
||||
let prevFilterActive = this.comunitaService.isFilterActive();
|
||||
@@ -482,17 +483,43 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.route.queryParams.subscribe(params => {
|
||||
if (params['import']) {
|
||||
this.handleImport(params['import']);
|
||||
let queryParamsSubscribed = false;
|
||||
effect(() => {
|
||||
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']) {
|
||||
this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']);
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
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']) {
|
||||
this.handleRemoteRestore(params['restore-uid']);
|
||||
}
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
let prevSelectionMode = false;
|
||||
|
||||
@@ -550,33 +577,7 @@ export class HomePage implements OnDestroy {
|
||||
}, { 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) {
|
||||
try {
|
||||
@@ -631,7 +632,11 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
if (this.playlistService.processImportJson(json)) {
|
||||
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) {
|
||||
console.error('Failed to import playlist', e);
|
||||
@@ -651,6 +656,7 @@ export class HomePage implements OnDestroy {
|
||||
});
|
||||
await loading.present();
|
||||
|
||||
let hasNavigatedToCanto = false;
|
||||
try {
|
||||
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
@@ -712,6 +718,7 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
// Select it immediately
|
||||
this.selectPlaylist(selectedPl);
|
||||
this.activeFilterType.set('playlist');
|
||||
|
||||
await loading.dismiss();
|
||||
|
||||
@@ -721,6 +728,12 @@ export class HomePage implements OnDestroy {
|
||||
color: 'success'
|
||||
});
|
||||
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 {
|
||||
throw new Error('Formato dati non valido.');
|
||||
}
|
||||
@@ -734,7 +747,9 @@ export class HomePage implements OnDestroy {
|
||||
});
|
||||
await alert.present();
|
||||
} 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) {
|
||||
clearInterval(this.updatePollInterval);
|
||||
}
|
||||
if (this.queryParamsSubscription) {
|
||||
this.queryParamsSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
onSearch(event: any) {
|
||||
@@ -1408,6 +1426,12 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
// Automatically save to the device!
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Previous line -->
|
||||
<div *ngIf="displayLines().prev" class="prev-line">
|
||||
<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 class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- Active line -->
|
||||
<div *ngIf="displayLines().current" class="active-line outfit-font">
|
||||
<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 class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Next line -->
|
||||
<div *ngIf="displayLines().next" class="next-line">
|
||||
<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 class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
|
||||
@@ -82,7 +82,7 @@ export class DisplayPage implements OnInit, OnDestroy {
|
||||
|
||||
private route = inject(ActivatedRoute);
|
||||
private cantiService = inject(CantiService);
|
||||
private lyricsParser = inject(LyricsParserService);
|
||||
public lyricsParser = inject(LyricsParserService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -11,14 +11,6 @@
|
||||
<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>{{ 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>
|
||||
</div>
|
||||
</ion-title>
|
||||
@@ -26,10 +18,10 @@
|
||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
</div>
|
||||
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()" 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-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"
|
||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||
[color]="showChords() ? 'secondary' : 'medium'"
|
||||
@@ -46,24 +38,59 @@
|
||||
<div class="lyrics-container"
|
||||
[style.fontSize.rem]="fontSize()"
|
||||
[class.full-screen-container]="settingsService.fullscreenMode()"
|
||||
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
|
||||
(touchstart)="onTouchStart($event)"
|
||||
(touchmove)="onTouchMove($event)"
|
||||
(touchend)="onTouchEnd()">
|
||||
|
||||
|
||||
<!-- Landscape Side Controls (Scrollable) -->
|
||||
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
|
||||
<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;">
|
||||
<ion-button fill="clear" (click)="restart()" style="height: 48px; margin: 0;">
|
||||
<ion-icon name="arrow-up-circle" color="secondary" style="font-size: 1.8rem;"></ion-icon>
|
||||
|
||||
<!-- Navigation Group -->
|
||||
<div class="side-group">
|
||||
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
|
||||
prv
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0" style="height: 48px; margin: 0;">
|
||||
<ion-icon name="chevron-up" color="secondary" style="font-size: 1.8rem;"></ion-icon>
|
||||
</div>
|
||||
<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 fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1" style="height: 48px; margin: 0;">
|
||||
<ion-icon name="chevron-down" color="secondary" style="font-size: 1.8rem;"></ion-icon>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -86,8 +113,8 @@
|
||||
[class.active]="isActiveLine(si, li)">
|
||||
|
||||
<!-- Chord mode: show chords above text -->
|
||||
<ng-container *ngIf="showChords(); else textOnly">
|
||||
<span *ngFor="let seg of line.segments" class="chord-segment">
|
||||
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
|
||||
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||
<span class="seg-text">{{ seg.text }}</span>
|
||||
</span>
|
||||
@@ -99,7 +126,7 @@
|
||||
</ng-template>
|
||||
</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>
|
||||
|
||||
@@ -107,33 +134,36 @@
|
||||
</ion-content>
|
||||
|
||||
<ion-footer class="ion-no-border">
|
||||
<!-- Audio Player Toolbar -->
|
||||
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
|
||||
<!-- Audio Player / Playlist Navigation Toolbar -->
|
||||
<ion-toolbar class="global-player-toolbar glass">
|
||||
<div class="player-content">
|
||||
<div class="controls-row">
|
||||
<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 fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
|
||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<ion-range
|
||||
[min]="0"
|
||||
[max]="youtubePlayerService.videoDuration()"
|
||||
[value]="youtubePlayerService.videoProgress()"
|
||||
(ionChange)="onSeek($event)"
|
||||
color="secondary"
|
||||
class="global-range">
|
||||
</ion-range>
|
||||
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
|
||||
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<ion-range
|
||||
[min]="0"
|
||||
[max]="youtubePlayerService.videoDuration()"
|
||||
[value]="youtubePlayerService.videoProgress()"
|
||||
(ionChange)="onSeek($event)"
|
||||
color="secondary"
|
||||
class="global-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-icon slot="icon-only" name="play-skip-forward-sharp"></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-icon slot="icon-only" name="chevron-forward"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,6 +197,18 @@
|
||||
</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) -->
|
||||
<div class="group" *ngIf="showChords()">
|
||||
@@ -182,38 +224,30 @@
|
||||
</ion-button>
|
||||
</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 -->
|
||||
<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-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-button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="group" *ngIf="connectivityService.isOnline()">
|
||||
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()">
|
||||
<!-- YouTube Link in Portrait Footer -->
|
||||
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
|
||||
<ion-button fill="clear" size="small" (click)="openYoutube()">
|
||||
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</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) -->
|
||||
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
|
||||
|
||||
@@ -172,6 +172,9 @@
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
min-height: 1.5em;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
|
||||
&.active {
|
||||
color: var(--ion-color-secondary);
|
||||
@@ -187,6 +190,10 @@
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.2em;
|
||||
|
||||
&.contiguous-next {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.chord {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
@@ -299,7 +306,6 @@
|
||||
100% { transform: scale(0.9); opacity: 0.6; }
|
||||
}
|
||||
|
||||
// Fullscreen and Orientation
|
||||
@media (orientation: landscape) {
|
||||
// Always hide footer in landscape as we have side controls
|
||||
ion-footer {
|
||||
@@ -309,22 +315,19 @@
|
||||
.lyrics-container {
|
||||
height: 100%;
|
||||
padding-top: 4px; // Minimized
|
||||
padding-bottom: 8px !important;
|
||||
padding-right: 90px !important; // More room for side controls and zoom
|
||||
}
|
||||
|
||||
.lyrics-view {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
ion-content {
|
||||
--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 {
|
||||
display: none !important; // Strict hidden in portrait
|
||||
|
||||
@@ -333,9 +336,9 @@ ion-content.full-screen-content {
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 44px !important; // Align with header
|
||||
top: 56px !important; // Align below header
|
||||
bottom: 0;
|
||||
width: 60px;
|
||||
width: 50px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -347,8 +350,10 @@ ion-content.full-screen-content {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 0;
|
||||
gap: 16px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 12px;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
@@ -356,22 +361,81 @@ ion-content.full-screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px; // Reduced for a more compact layout
|
||||
padding-bottom: 20px;
|
||||
gap: 8px; // Reduced for a more compact layout
|
||||
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 {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
margin: 0;
|
||||
height: 48px;
|
||||
ion-icon { font-size: 1.8rem; }
|
||||
height: 38px;
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
ion-content.full-screen-content {
|
||||
--offset-bottom: 0px !important;
|
||||
|
||||
@media (orientation: portrait) {
|
||||
--offset-bottom: 48px !important; // Footer height
|
||||
}
|
||||
}
|
||||
|
||||
.mic-sensitivity-overlay {
|
||||
position: fixed;
|
||||
@@ -673,6 +737,13 @@ ion-content.full-screen-content {
|
||||
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 {
|
||||
background: rgba(0, 0, 0, 0.05) !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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { ActivatedRoute, Router } from '@angular/router';
|
||||
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 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 */
|
||||
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
|
||||
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);
|
||||
} else {
|
||||
sections = this.lyricsParser.parseText(c.testo);
|
||||
@@ -68,7 +89,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Apply transposition if in chords mode
|
||||
if (this.showChords()) {
|
||||
if (activeShowChords) {
|
||||
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 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 initialFontSize: number = 1.0;
|
||||
|
||||
@@ -104,7 +128,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
public router = inject(Router);
|
||||
public cantiService = inject(CantiService);
|
||||
private lyricsParser = inject(LyricsParserService);
|
||||
public lyricsParser = inject(LyricsParserService);
|
||||
private alertCtrl = inject(AlertController);
|
||||
private toastCtrl = inject(ToastController);
|
||||
public themeService = inject(ThemeService);
|
||||
@@ -210,6 +234,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (scrollEl) {
|
||||
scrollEl.scrollTop = 0;
|
||||
}
|
||||
this.checkLandscapeZoom();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -269,9 +294,147 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.autoscrollSpeed.set(2);
|
||||
}
|
||||
}, { 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() {
|
||||
this.checkLandscapeZoom();
|
||||
const gestureX = this.gestureCtrl.create({
|
||||
el: this.el.nativeElement,
|
||||
direction: 'x',
|
||||
@@ -291,6 +454,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
gestureX.enable();
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -345,9 +509,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
const currentDistance = this.getDistance(event.touches[0], event.touches[1]);
|
||||
const ratio = currentDistance / this.initialPinchDistance;
|
||||
let newSize = this.initialFontSize * ratio;
|
||||
newSize = Math.max(this.MIN_FONT, Math.min(this.MAX_FONT, newSize));
|
||||
if (Math.abs(newSize - this.fontSize()) > 0.01) {
|
||||
this.fontSize.set(newSize);
|
||||
const minZ = this.minZoom();
|
||||
const maxZ = this.maxZoom();
|
||||
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() });
|
||||
}
|
||||
}
|
||||
@@ -358,6 +528,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.updatePlaylistSettings();
|
||||
}
|
||||
|
||||
|
||||
private getDistance(t1: Touch, t2: Touch): number {
|
||||
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.channel.postMessage({ type: 'SYNC_CHORDS', showChords: this.showChords() });
|
||||
this.transposeAmount.set(0);
|
||||
this.checkLandscapeZoom();
|
||||
}
|
||||
|
||||
private updatePlaylistSettings() {
|
||||
@@ -401,16 +573,26 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
if (this.fontSize() < this.MAX_FONT) {
|
||||
this.fontSize.update(v => Math.min(v + this.FONT_STEP, this.MAX_FONT));
|
||||
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||
this.updatePlaylistSettings();
|
||||
const maxZ = this.maxZoom();
|
||||
if (this.fontSize() < maxZ) {
|
||||
const target = Math.min(this.fontSize() + this.FONT_STEP, maxZ);
|
||||
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() {
|
||||
if (this.fontSize() > this.MIN_FONT) {
|
||||
this.fontSize.update(v => Math.max(v - this.FONT_STEP, this.MIN_FONT));
|
||||
const minZ = this.minZoom();
|
||||
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.updatePlaylistSettings();
|
||||
}
|
||||
@@ -442,7 +624,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.currentLineIndex.set(0);
|
||||
this.youtubePlayerService.seekTo(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 {
|
||||
@@ -600,13 +788,16 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
if (foundNextIdx !== -1) {
|
||||
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx);
|
||||
return foundNextIdx;
|
||||
// Overlap by 1 line to ensure the last visible line is repeated at the top of the next page
|
||||
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();
|
||||
return Math.min(totalLines - 1, currentIdx + step);
|
||||
const targetIdx = Math.max(currentIdx + 1, currentIdx + step - 1);
|
||||
return Math.min(totalLines - 1, targetIdx);
|
||||
} catch (e) {
|
||||
console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e);
|
||||
return this.currentLineIndex() + 1;
|
||||
@@ -614,6 +805,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
next(isAutomatic: boolean = false, isVisual: boolean = false) {
|
||||
if (this.isBlackScreen()) {
|
||||
this.deactivateBlackScreen();
|
||||
return;
|
||||
}
|
||||
this.lastAdvanceTimestamp = Date.now();
|
||||
|
||||
const totalLines = this.getTotalLines();
|
||||
@@ -638,7 +833,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
nextIdx = this.calculateNextPageStartIndex();
|
||||
}
|
||||
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
|
||||
nextIdx = this.calculateNextPageStartIndex();
|
||||
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
|
||||
|
||||
prev(isVisual: boolean = false) {
|
||||
if (this.isBlackScreen()) {
|
||||
this.deactivateBlackScreen();
|
||||
return;
|
||||
}
|
||||
this.lastAdvanceTimestamp = Date.now();
|
||||
|
||||
const prevIdx = this.currentLineIndex();
|
||||
if (prevIdx > 0) {
|
||||
const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual;
|
||||
const isPageMode = (this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive();
|
||||
const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
|
||||
const nextIdx = Math.max(0, prevIdx - stepSize);
|
||||
|
||||
@@ -690,6 +889,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
const index = list.indexOf(currentId);
|
||||
if (index >= 0 && index < list.length - 1) {
|
||||
const nextId = list[index + 1];
|
||||
if (this.isLandscapeActive()) {
|
||||
this.isBlackScreen.set(true);
|
||||
}
|
||||
this.router.navigate(['/player'], { queryParams: { id: nextId } });
|
||||
} else {
|
||||
this.playlistService.autoPlayPlaylist.set(false);
|
||||
@@ -708,6 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
const index = list.indexOf(currentId);
|
||||
if (index > 0) {
|
||||
const prevId = list[index - 1];
|
||||
if (this.isLandscapeActive()) {
|
||||
this.isBlackScreen.set(true);
|
||||
}
|
||||
this.router.navigate(['/player'], { queryParams: { id: prevId } });
|
||||
}
|
||||
}
|
||||
@@ -949,6 +1154,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.exitFullscreen();
|
||||
this.stopAutoscroll();
|
||||
this.logPreviousSongTime();
|
||||
this.stopCameraNavigation();
|
||||
|
||||
@@ -31,107 +31,159 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-list lines="none" class="input-list">
|
||||
<ion-item class="custom-input-item">
|
||||
<ion-label position="stacked">Titolo del Canto</ion-label>
|
||||
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||
</ion-item>
|
||||
<div class="editor-preview-split">
|
||||
<!-- Left Column: Inputs & Editor -->
|
||||
<div class="editor-column">
|
||||
<ion-list lines="none" class="input-list">
|
||||
<ion-item class="custom-input-item">
|
||||
<ion-label position="stacked">Titolo del Canto</ion-label>
|
||||
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<div class="category-selectors">
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
||||
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
|
||||
{{ lit.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
<div class="category-selectors">
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
||||
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
|
||||
{{ lit.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Periodo / Tema</ion-label>
|
||||
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
||||
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
|
||||
{{ tem.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</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>
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Periodo / Tema</ion-label>
|
||||
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
||||
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
|
||||
{{ tem.tag_name }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</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>
|
||||
|
||||
<!-- 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 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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ body.high-contrast :host ::ng-deep {
|
||||
}
|
||||
|
||||
.propose-container {
|
||||
max-width: 800px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
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 { ThemeService } from '../../services/theme.service';
|
||||
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
|
||||
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-propose-canto',
|
||||
@@ -27,6 +28,17 @@ export class ProposeCantoPage implements OnInit {
|
||||
public themeService = inject(ThemeService);
|
||||
private route = inject(ActivatedRoute);
|
||||
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 = '';
|
||||
author: string = '';
|
||||
|
||||
@@ -62,14 +62,7 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
|
||||
</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-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
|
||||
@@ -347,15 +347,33 @@ export class SettingsPage {
|
||||
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
|
||||
if (this.swUpdate.isEnabled) {
|
||||
const swFoundUpdate = await this.swUpdate.checkForUpdate();
|
||||
if (swFoundUpdate) {
|
||||
await this.applyUpdateAndReload();
|
||||
await this.applyUpdateAndReload(readyPromise, sub);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sub) sub.unsubscribe();
|
||||
|
||||
// Layer 3: Fallback — check version.json
|
||||
const versionMismatch = await this.checkVersionJson();
|
||||
if (versionMismatch) {
|
||||
@@ -377,7 +395,7 @@ export class SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
private async applyUpdateAndReload() {
|
||||
private async applyUpdateAndReload(readyPromise?: Promise<void>, subscription?: any) {
|
||||
const overlay = showFullscreenUpdateOverlay();
|
||||
|
||||
let activated = false;
|
||||
@@ -397,24 +415,19 @@ export class SettingsPage {
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// Listen for VERSION_READY + activate + reload
|
||||
if (this.swUpdate.isEnabled) {
|
||||
this.swUpdate.versionUpdates
|
||||
.pipe(
|
||||
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
|
||||
first()
|
||||
)
|
||||
.subscribe(() => {
|
||||
console.log('[PWA-Update] Settings: version ready, activating...');
|
||||
activateAndReload();
|
||||
});
|
||||
if (readyPromise) {
|
||||
Promise.race([
|
||||
readyPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 25000))
|
||||
]).then(() => {
|
||||
if (subscription) subscription.unsubscribe();
|
||||
activateAndReload();
|
||||
});
|
||||
} else {
|
||||
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> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { HttpClient, HttpEventType } from '@angular/common/http';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
export interface Canto {
|
||||
id: string;
|
||||
@@ -34,6 +35,7 @@ export interface CantoEseguito {
|
||||
export class CantiService {
|
||||
private http = inject(HttpClient);
|
||||
private storage = inject(Storage);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
public canti = signal<Canto[]>([]);
|
||||
@@ -58,6 +60,20 @@ export class CantiService {
|
||||
await this.loadFromStorage();
|
||||
if (this.canti() && this.canti().length > 0) {
|
||||
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();
|
||||
}
|
||||
@@ -85,10 +101,16 @@ export class CantiService {
|
||||
}).subscribe({
|
||||
next: async (event: any) => {
|
||||
if (event.type === HttpEventType.DownloadProgress) {
|
||||
let pct = 0;
|
||||
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 {
|
||||
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) {
|
||||
const response = event.body;
|
||||
@@ -137,12 +159,19 @@ export class CantiService {
|
||||
this.progress.set(100);
|
||||
this.loading.set(false);
|
||||
this.firstLoadCompleted.set(true);
|
||||
|
||||
if (this.settingsService.isVersionCheckComplete() && (window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch canti', error);
|
||||
this.loading.set(false);
|
||||
this.firstLoadCompleted.set(true);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export class LyricsParserService {
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export class LyricsParserService {
|
||||
|
||||
const upperChord = chord.toUpperCase();
|
||||
for (const r of possibleRoots) {
|
||||
if (upperChord.startsWith(r)) {
|
||||
if (upperChord.startsWith(r.toUpperCase())) {
|
||||
root = r;
|
||||
suffix = chord.substring(r.length);
|
||||
break;
|
||||
@@ -206,8 +206,11 @@ export class LyricsParserService {
|
||||
|
||||
if (!root) return chord;
|
||||
|
||||
let index = this.scale.indexOf(root);
|
||||
if (index === -1) index = this.flatScale.indexOf(root);
|
||||
const upperRoot = root.toUpperCase();
|
||||
let index = this.scale.indexOf(upperRoot);
|
||||
if (index === -1) {
|
||||
index = this.flatScale.findIndex(n => n.toUpperCase() === upperRoot);
|
||||
}
|
||||
if (index === -1) return chord;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export class SettingsService {
|
||||
public isStandalone = signal<boolean>(false);
|
||||
public isIos = signal<boolean>(false);
|
||||
public isAndroid = signal<boolean>(false);
|
||||
public isVersionCheckComplete = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Gestione/Generazione ID utente univoco
|
||||
|
||||
@@ -22,12 +22,6 @@ export class YoutubePlayerService {
|
||||
if (!this.connectivityService.isOnline()) {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.06.12.1712';
|
||||
export const VERSION = '2026.06.14.1936';
|
||||
|
||||
+146
-68
@@ -34,77 +34,155 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root>
|
||||
<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="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 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 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>
|
||||
<script>
|
||||
(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)
|
||||
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>
|
||||
</div>
|
||||
|
||||
<app-root></app-root>
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user