chore: save current changes

This commit is contained in:
David Frassi
2026-06-06 08:19:08 +02:00
parent e7e43468b3
commit 4d1883a45d
23 changed files with 1028 additions and 327 deletions
+5
View File
@@ -37,6 +37,11 @@ fi
echo "✅ Build completata con successo in: $DIST_PATH" echo "✅ Build completata con successo in: $DIST_PATH"
# --- Genera version.json per il polling PWA ---
BUILD_TIMESTAMP=$(date +%s)000
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
# 2.5 In locale usiamo la root # 2.5 In locale usiamo la root
echo "📁 Build pronta nella root..." echo "📁 Build pronta nella root..."
# Nessuna sottocartella ionic necessaria in locale # Nessuna sottocartella ionic necessaria in locale
+5
View File
@@ -51,5 +51,10 @@ if [ ! -d "www" ]; then
exit 1 exit 1
fi fi
# --- Genera version.json per il polling PWA ---
BUILD_TIMESTAMP=$(date +%s)000
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
# --- Upload via FTP --- # --- Upload via FTP ---
python3 scratch/deploy_ftp.py python3 scratch/deploy_ftp.py
+1 -1
View File
@@ -11,7 +11,7 @@
<IfModule mod_headers.c> <IfModule mod_headers.c>
# Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker # Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker
<FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest"> <FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest|version\.json">
Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache" Header set Pragma "no-cache"
Header set Expires 0 Header set Expires 0
+60 -123
View File
@@ -1,8 +1,5 @@
import { Component, inject, ApplicationRef } from '@angular/core'; import { Component, inject } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { ThemeService } from './services/theme.service';
import { filter, first } from 'rxjs/operators';
import { concat, interval, fromEvent } from 'rxjs';
import { ToastController } from '@ionic/angular';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -11,125 +8,65 @@ import { ToastController } from '@ionic/angular';
standalone: false, standalone: false,
}) })
export class AppComponent { export class AppComponent {
private swUpdate = inject(SwUpdate); private themeService = inject(ThemeService); // Ensures theme is initialized at boot
private appRef = inject(ApplicationRef);
private toastCtrl = inject(ToastController);
constructor() { constructor() {
this.setupUpdates(); // Gli aggiornamenti automatici e periodici sono stati rimossi.
} // L'aggiornamento viene gestito esclusivamente in modo manuale
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
private async forceBypassCacheAndCheck(): Promise<boolean> {
try {
// 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.update();
console.log('[PWA-Update] Native Service Worker updated');
}
}
// 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali
await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' });
await fetch('/ngsw.json', { cache: 'reload' });
console.log('[PWA-Update] Caches successfully busted for ngsw.json');
} catch (e) {
console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e);
}
return await this.swUpdate.checkForUpdate();
}
private setupUpdates() {
if (this.swUpdate.isEnabled) {
const launchTime = Date.now();
// Ricarica automaticamente se il Service Worker controller cambia per garantire la freschezza immediata
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[PWA-Update] Controller changed. Reloading page...');
window.location.reload();
});
}
// 1. Sottoscrizione all'evento di versione scaricata/pronta
this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
.subscribe(async () => {
const timeSinceLaunch = Date.now() - launchTime;
console.log(`[PWA-Update] New version ready! Time since launch: ${timeSinceLaunch}ms`);
if (timeSinceLaunch < 8000) {
// Se l'applicazione è appena stata aperta (< 8s), la aggiorniamo ed eseguiamo il reload immediato e silenzioso
console.log('[PWA-Update] Auto-activating update on startup...');
try {
await this.swUpdate.activateUpdate();
console.log('[PWA-Update] Update activated successfully, reloading page...');
window.location.reload();
} catch (err) {
console.error('[PWA-Update] Auto-activation failed on startup:', err);
// Fallback: ricarica comunque per provare a forzare l'attivazione
window.location.reload();
}
} else {
// Altrimenti mostriamo il prompt interattivo per evitare interruzioni improvvise
console.log('[PWA-Update] Showing toast prompt for active user...');
const toast = await this.toastCtrl.create({
message: 'Nuova versione dell\'applicazione disponibile!',
position: 'bottom',
color: 'secondary',
buttons: [
{
text: 'Aggiorna',
role: 'cancel',
handler: () => {
console.log('[PWA-Update] Activating update and reloading...');
this.swUpdate.activateUpdate().then(() => {
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
});
}
}
]
});
await toast.present();
}
});
// 2. Controllo IMMEDIATO all'avvio dell'applicazione (senza attendere lo stato isStable)
console.log('[PWA-Update] Startup: Checking for PWA updates immediately...');
this.forceBypassCacheAndCheck().catch(err => {
console.warn('[PWA-Update] Startup update check failed:', err);
});
// 3. Attiva i controlli periodici in background solo DOPO la stabilizzazione dell'app
this.appRef.isStable.pipe(
filter(stable => stable),
first()
).subscribe(() => {
console.log('[PWA-Update] App is stable. Initializing background updates...');
// Controllo periodico ogni 60 secondi
const every60Seconds$ = interval(60 * 1000);
every60Seconds$.subscribe(async () => {
console.log('[PWA-Update] Periodic check for updates (every 60s)...');
try {
await this.forceBypassCacheAndCheck();
} catch (err) {
console.warn('[PWA-Update] Periodic update check failed:', err);
}
});
});
// 4. Controllo quando l'utente torna sulla scheda o ripristina l'app (Visibility Change)
fromEvent(document, 'visibilitychange')
.pipe(filter(() => document.visibilityState === 'visible'))
.subscribe(async () => {
console.log('[PWA-Update] App resumed, checking for PWA updates...');
try {
await this.forceBypassCacheAndCheck();
} catch (err) {
console.warn('[PWA-Update] Resume update check failed:', err);
}
});
}
} }
} }
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%;">
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 10px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Aggiornamento in corso</h2>
<p style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 30px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Installazione della nuova versione...</p>
<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);
let percent = 0;
const interval = setInterval(() => {
if (percent < 95) {
percent += Math.floor(Math.random() * 5) + 2;
if (percent > 95) percent = 95;
updatePercent(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);
}
};
}
+5 -2
View File
@@ -5,7 +5,10 @@
<img src="assets/icon/favicon.png" class="header-logo"> <img src="assets/icon/favicon.png" class="header-logo">
<div class="header-text-group"> <div class="header-text-group">
<span class="app-name">{{ appName }}</span> <span class="app-name">{{ appName }}</span>
<span class="version-badge">v{{ version }}</span> <span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
v{{ version }}
<ion-icon name="refresh-outline" style="font-size: 0.9em;"></ion-icon>
</span>
</div> </div>
</div> </div>
</ion-title> </ion-title>
@@ -283,7 +286,7 @@
<!-- Selection Area --> <!-- Selection Area -->
<div class="selection-column" (click)="playlistService.toggleSongSelection(canto.id); $event.stopPropagation()"> <div class="selection-column" (click)="playlistService.toggleSongSelection(canto.id); $event.stopPropagation()">
<span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)"> <span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)">
{{ canto.id.startsWith('my_') ? 'M' : canto.id_canti }} {{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
</span> </span>
</div> </div>
+123
View File
@@ -16,6 +16,9 @@ import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.componen
import { CantiLettureService } from '../services/canti-letture.service'; import { CantiLettureService } from '../services/canti-letture.service';
import { ComunitaService } from '../services/comunita.service'; import { ComunitaService } from '../services/comunita.service';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators';
import { showFullscreenUpdateOverlay } from '../app.component';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@@ -67,9 +70,123 @@ export class HomePage implements OnDestroy {
private alertCtrl = inject(AlertController); private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
private loadingCtrl = inject(LoadingController); private loadingCtrl = inject(LoadingController);
private swUpdate = inject(SwUpdate);
private firstInteraction = true; private firstInteraction = true;
async checkForAppUpdate(event?: Event) {
if (event) event.stopPropagation();
if (!this.swUpdate.isEnabled) {
const toast = await this.toastCtrl.create({
message: 'Aggiornamenti non supportati su questo browser.',
duration: 3000,
color: 'medium'
});
await toast.present();
return;
}
const toastLoading = await this.toastCtrl.create({
message: 'Ricerca aggiornamenti in corso...',
duration: 1500,
color: 'secondary'
});
await toastLoading.present();
const updateAvailable = await this.performUpdateCheck();
if (!updateAvailable) {
const toast = await this.toastCtrl.create({
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
duration: 3000,
color: 'success'
});
await toast.present();
}
}
private async performUpdateCheck(): Promise<boolean> {
try {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.update();
}
if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate();
if (swFoundUpdate) {
await this.applyUpdateAndReload();
return true;
}
}
const versionMismatch = await this.checkVersionJson();
if (versionMismatch) {
await this.applyUpdateAndReload();
return true;
}
return false;
} catch (err) {
console.error('[PWA-Update] Update check failed from home:', err);
const toast = await this.toastCtrl.create({
message: 'Errore durante la ricerca di aggiornamenti.',
duration: 3000,
color: 'danger'
});
await toast.present();
return false;
}
}
private async applyUpdateAndReload() {
const overlay = showFullscreenUpdateOverlay();
let activated = false;
const activateAndReload = async () => {
if (activated) return;
activated = true;
try {
if (this.swUpdate.isEnabled) {
await this.swUpdate.activateUpdate();
}
} catch (e) {
console.warn('[PWA-Update] activateUpdate failed:', e);
}
overlay.finish();
setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 600);
};
if (this.swUpdate.isEnabled) {
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
activateAndReload();
});
}
setTimeout(() => {
activateAndReload();
}, 6000);
}
private async checkVersionJson(): Promise<boolean> {
try {
const response = await fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) return false;
const data = await response.json();
return data.version !== VERSION;
} catch (err) {
return false;
}
}
onInteraction() { onInteraction() {
if (this.firstInteraction) { if (this.firstInteraction) {
this.firstInteraction = false; this.firstInteraction = false;
@@ -1104,6 +1221,12 @@ export class HomePage implements OnDestroy {
return info && info.num_canto ? info.num_canto.toString() : null; return info && info.num_canto ? info.num_canto.toString() : null;
} }
getMySongNumber(canto: any): number {
if (!canto || !canto.id) return 0;
const index = this.myCantiService.myCanti().findIndex(c => c.id === canto.id);
return index !== -1 ? index + 1 : 0;
}
toggleComunitaFilter() { toggleComunitaFilter() {
if (!this.comunitaService.comunitaCode()) { if (!this.comunitaService.comunitaCode()) {
this.promptComunitaCode(); this.promptComunitaCode();
+3
View File
@@ -74,6 +74,9 @@
.seg-text { .seg-text {
white-space: pre-wrap; white-space: pre-wrap;
&::after {
content: '\200b';
}
} }
.footer-info { .footer-info {
+5 -2
View File
@@ -6,7 +6,7 @@
<ion-title class="outfit-font wrapped-title"> <ion-title class="outfit-font wrapped-title">
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1"> <div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
<span class="canto-number" *ngIf="canto()?.id_canti"> <span class="canto-number" *ngIf="canto()?.id_canti">
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }} {{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span> </span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span> <span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
@@ -26,6 +26,9 @@
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()"> <div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon> <ion-icon name="cloud-offline-outline"></ion-icon>
</div> </div>
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-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()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" <ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'" [name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
@@ -153,7 +156,7 @@
</div> </div>
<!-- Camera Head Gestures Toggle in Portrait --> <!-- Camera Head Gestures Toggle in Portrait -->
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'"> <ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" *ngIf="settingsService.enableVisualAutoscroll()">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon> <ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button> </ion-button>
+17
View File
@@ -197,6 +197,9 @@
.seg-text { .seg-text {
white-space: pre; white-space: pre;
&::after {
content: '\200b';
}
} }
} }
@@ -634,6 +637,20 @@ ion-content.full-screen-content {
} }
:host-context(body.high-contrast) { :host-context(body.high-contrast) {
.slim-toolbar {
--background: #ffffff !important;
background: #ffffff !important;
border-top: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.landscape-side-controls {
--background: #ffffff !important;
background: #ffffff !important;
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.slim-controls .group { .slim-controls .group {
background: rgba(0, 0, 0, 0.05) !important; background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important; border: 1px solid rgba(0, 0, 0, 0.15) !important;
+87 -22
View File
@@ -412,18 +412,29 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const container = document.querySelector('.lyrics-container') as HTMLElement; const container = document.querySelector('.lyrics-container') as HTMLElement;
if (!container) return 3; // Ritorno generico se il contenitore non è pronto if (!container) return 3; // Ritorno generico se il contenitore non è pronto
const containerHeight = container.clientHeight; const containerRect = container.getBoundingClientRect();
const visibleTop = Math.max(containerRect.top, 0);
// Calcoliamo la reale altezza visibile sottraendo l'eventuale footer in sovraimpressione let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight);
let visibleHeight = containerHeight;
const footer = document.querySelector('ion-footer') as HTMLElement; const footer = document.querySelector('ion-footer') as HTMLElement;
if (footer && footer.offsetHeight > 0) { if (footer) {
const computedStyle = window.getComputedStyle(footer); const footerRect = footer.getBoundingClientRect();
if (computedStyle.display !== 'none') { if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') {
visibleHeight -= footer.offsetHeight; visibleBottom = Math.min(visibleBottom, footerRect.top);
} }
} }
const floatingBar = document.querySelector('.floating-autoscroll-bar') as HTMLElement;
if (floatingBar) {
const barRect = floatingBar.getBoundingClientRect();
if (barRect.height > 0 && window.getComputedStyle(floatingBar).display !== 'none') {
visibleBottom = Math.min(visibleBottom, barRect.top);
}
}
// Calcoliamo la reale altezza visibile
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const lineElems = document.querySelectorAll('.lyric-line'); const lineElems = document.querySelectorAll('.lyric-line');
if (lineElems.length === 0) return 3; if (lineElems.length === 0) return 3;
@@ -443,7 +454,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const pageStep = Math.max(1, linesPerPage); const pageStep = Math.max(1, linesPerPage);
console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', { console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', {
containerHeight,
visibleHeight, visibleHeight,
avgLineHeight, avgLineHeight,
linesPerPage, linesPerPage,
@@ -463,10 +473,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (!container) return []; if (!container) return [];
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const visibleTop = containerRect.top; const visibleTop = Math.max(containerRect.top, 0);
let visibleBottom = containerRect.bottom; let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight);
// Sottrai l'altezza dell'eventuale footer sovrapposto ad alto z-index // Sottrai l'altezza dell'eventuale footer o barra sovrapposti ad alto z-index
const footer = document.querySelector('ion-footer') as HTMLElement; const footer = document.querySelector('ion-footer') as HTMLElement;
if (footer) { if (footer) {
const footerRect = footer.getBoundingClientRect(); const footerRect = footer.getBoundingClientRect();
@@ -475,6 +485,14 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
const floatingBar = document.querySelector('.floating-autoscroll-bar') as HTMLElement;
if (floatingBar) {
const barRect = floatingBar.getBoundingClientRect();
if (barRect.height > 0 && window.getComputedStyle(floatingBar).display !== 'none') {
visibleBottom = Math.min(visibleBottom, barRect.top);
}
}
const lineElems = document.querySelectorAll('.lyric-line'); const lineElems = document.querySelectorAll('.lyric-line');
const visibleIndices: number[] = []; const visibleIndices: number[] = [];
@@ -501,7 +519,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (!container) return this.currentLineIndex() + 1; if (!container) return this.currentLineIndex() + 1;
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
let visibleBottom = containerRect.bottom; // Assicuriamoci che il limite inferiore non superi l'altezza reale della finestra
let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight);
// Trova la posizione del footer o di qualunque elemento sovrapposto in fondo // Trova la posizione del footer o di qualunque elemento sovrapposto in fondo
const footer = document.querySelector('ion-footer') as HTMLElement; const footer = document.querySelector('ion-footer') as HTMLElement;
@@ -512,8 +531,18 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
// Sottrai un piccolo margine di tolleranza di 8px const floatingBar = document.querySelector('.floating-autoscroll-bar') as HTMLElement;
const visibleLimitY = visibleBottom - 8; if (floatingBar) {
const barRect = floatingBar.getBoundingClientRect();
if (barRect.height > 0 && window.getComputedStyle(floatingBar).display !== 'none') {
visibleBottom = Math.min(visibleBottom, barRect.top);
}
}
// Aumentiamo il margine di tolleranza a 24px (o più) per essere sicuri
// di non perdere mai una riga parzialmente coperta. Meglio rileggere una riga
// in cima alla pagina successiva che perderla completamente a causa dello zoom.
const visibleLimitY = visibleBottom - 24;
const lineElems = document.querySelectorAll('.lyric-line'); const lineElems = document.querySelectorAll('.lyric-line');
const totalLines = this.getTotalLines(); const totalLines = this.getTotalLines();
@@ -546,7 +575,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
next(isAutomatic: boolean = false) { next(isAutomatic: boolean = false, isVisual: boolean = false) {
this.lastAdvanceTimestamp = Date.now(); this.lastAdvanceTimestamp = Date.now();
const totalLines = this.getTotalLines(); const totalLines = this.getTotalLines();
@@ -571,8 +600,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
nextIdx = this.calculateNextPageStartIndex(); nextIdx = this.calculateNextPageStartIndex();
} }
this.lastScrollBlock.set('center'); this.lastScrollBlock.set('center');
} else if (this.settingsService.karaokePageScrollMode()) { } else if (this.settingsService.karaokePageScrollMode() || isVisual) {
// Modalità manuale a pagine: calcolo analitico preciso per non perdere righe coperte // Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte
nextIdx = this.calculateNextPageStartIndex(); nextIdx = this.calculateNextPageStartIndex();
this.lastScrollBlock.set('start'); this.lastScrollBlock.set('start');
} else { } else {
@@ -588,12 +617,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
// Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti // Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti
prev() { prev(isVisual: boolean = false) {
this.lastAdvanceTimestamp = Date.now(); this.lastAdvanceTimestamp = Date.now();
const prevIdx = this.currentLineIndex(); const prevIdx = this.currentLineIndex();
if (prevIdx > 0) { if (prevIdx > 0) {
const isPageMode = this.settingsService.karaokePageScrollMode(); const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual;
const stepSize = isPageMode ? this.calculatePageStepSize() : 1; const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
const nextIdx = Math.max(0, prevIdx - stepSize); const nextIdx = Math.max(0, prevIdx - stepSize);
@@ -710,6 +739,36 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
async editOrCloneCanto() {
const c = this.canto();
if (!c) return;
if (
this.settingsService.comunitaEnabled() &&
this.settingsService.showEditor() &&
this.comunitaService.comunitaCode() &&
!c.id.startsWith('my_')
) {
// Clone the song: Title: <original title>
const clonedTitle = c.titolo;
const clonedCanto = await this.myCantiService.saveCanto({
titolo: clonedTitle,
autore: c.autore,
link_youtube: c.link_youtube,
testo: c.testo || c.accordi || '',
accordi: c.accordi || c.testo || '',
id_momenti: c.id_momenti || []
});
// Redirect to the edit page for the newly cloned song
this.router.navigate(['/propose-canto'], { queryParams: { editId: clonedCanto.id } });
} else {
// Standard edit path
this.router.navigate(['/propose-canto'], { queryParams: { editId: c.id } });
}
}
private initPlayer(id: string) { private initPlayer(id: string) {
if (!this.youtubePlayerService.isPlayerSupported()) { if (!this.youtubePlayerService.isPlayerSupported()) {
return; return;
@@ -757,6 +816,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
return info && info.num_canto ? info.num_canto.toString() : null; return info && info.num_canto ? info.num_canto.toString() : null;
} }
getMySongNumber(canto: any): number {
if (!canto || !canto.id) return 0;
const index = this.myCantiService.myCanti().findIndex(c => c.id === canto.id);
return index !== -1 ? index + 1 : 0;
}
toggleAutoscroll() { toggleAutoscroll() {
if (this.isAutoscrolling()) { if (this.isAutoscrolling()) {
this.stopAutoscroll(); this.stopAutoscroll();
@@ -845,9 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
await this.faceDetector.start(videoEl, (direction) => { await this.faceDetector.start(videoEl, (direction) => {
console.log(`[PlayerPage] Head tilt trigger received: ${direction}`); console.log(`[PlayerPage] Head tilt trigger received: ${direction}`);
if (direction === 'next') { if (direction === 'next') {
this.next(false); this.next(false, true);
} else { } else {
this.prev(); this.prev(true);
} }
}); });
} catch (e) { } catch (e) {
+1 -1
View File
@@ -24,7 +24,7 @@
<ion-icon name="reorder-two-outline"></ion-icon> <ion-icon name="reorder-two-outline"></ion-icon>
</div> </div>
<div class="song-info"> <div class="song-info">
<span class="canto-number">{{ song.id_canti }}</span> <span class="canto-number">{{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }}</span>
<span class="song-title"> <span class="song-title">
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }} <span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
</span> </span>
+6
View File
@@ -227,4 +227,10 @@ export class PlaylistPage {
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id)); const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
return info && info.num_canto ? info.num_canto.toString() : null; return info && info.num_canto ? info.num_canto.toString() : null;
} }
getMySongNumber(song: any): number {
if (!song || !song.id) return 0;
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
return index !== -1 ? index + 1 : 0;
}
} }
@@ -7,7 +7,20 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content class="ion-padding bg-gradient" [class.high-contrast-mode]="isHighContrast"> <ion-content class="ion-padding bg-gradient"
[class.high-contrast-mode]="isHighContrast"
[class.drag-over]="isDraggingOver"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)">
<div class="drag-overlay" *ngIf="isDraggingOver">
<div class="drag-message">
<ion-icon name="image-outline"></ion-icon>
<p>Rilascia l'immagine qui per estrarre il testo</p>
</div>
</div>
<div class="propose-container"> <div class="propose-container">
<!-- OCR Progress --> <!-- OCR Progress -->
<div class="ocr-progress-card" *ngIf="isProcessingOCR"> <div class="ocr-progress-card" *ngIf="isProcessingOCR">
@@ -101,7 +114,8 @@
[(ngModel)]="content" [(ngModel)]="content"
placeholder="Scrivi o scansiona..." placeholder="Scrivi o scansiona..."
rows="18" rows="18"
class="content-textarea"> class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea> </ion-textarea>
</ion-item> </ion-item>
</div> </div>
@@ -419,3 +419,54 @@ body.high-contrast :host ::ng-deep {
} }
} }
} }
/* DRAG AND DROP OVERLAY */
.drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
.drag-message {
text-align: center;
color: var(--ion-color-secondary);
background: rgba(255, 255, 255, 0.1);
border: 3px dashed var(--ion-color-secondary);
border-radius: 20px;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
ion-icon {
font-size: 64px;
}
p {
font-size: 20px;
font-weight: 700;
margin: 0;
}
}
}
body.high-contrast :host ::ng-deep {
.drag-overlay {
background: rgba(255, 255, 255, 0.9);
.drag-message {
color: #000000;
background: #ffffff;
border: 3px dashed #000000;
}
}
}
+261 -16
View File
@@ -6,13 +6,14 @@ import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service'; import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service'; import { MyCantiService } from '../../services/my-canti.service';
import { ThemeService } from '../../services/theme.service'; import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({ @Component({
selector: 'app-propose-canto', selector: 'app-propose-canto',
templateUrl: './propose-canto.page.html', templateUrl: './propose-canto.page.html',
styleUrls: ['./propose-canto.page.scss'], styleUrls: ['./propose-canto.page.scss'],
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, IonicModule] imports: [CommonModule, FormsModule, IonicModule, RouterModule]
}) })
export class ProposeCantoPage implements OnInit { export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea; @ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
@@ -22,12 +23,14 @@ export class ProposeCantoPage implements OnInit {
private myCantiService = inject(MyCantiService); private myCantiService = inject(MyCantiService);
private navCtrl = inject(NavController); private navCtrl = inject(NavController);
public themeService = inject(ThemeService); public themeService = inject(ThemeService);
private route = inject(ActivatedRoute);
title: string = ''; title: string = '';
author: string = ''; author: string = '';
youtubeLink: string = ''; youtubeLink: string = '';
selectedLiturgico: number[] = []; selectedLiturgico: number[] = [];
selectedTematico: number[] = []; selectedTematico: number[] = [];
editId: string | null = null;
private _content: string = ''; private _content: string = '';
get content(): string { return this._content; } get content(): string { return this._content; }
@@ -41,6 +44,7 @@ export class ProposeCantoPage implements OnInit {
undoStack: string[] = []; undoStack: string[] = [];
isProcessingOCR: boolean = false; isProcessingOCR: boolean = false;
ocrProgress: number = 0; ocrProgress: number = 0;
isDraggingOver: boolean = false;
get isHighContrast(): boolean { return this.themeService.highContrast(); } get isHighContrast(): boolean { return this.themeService.highContrast(); }
groupedChords = [ groupedChords = [
@@ -104,6 +108,31 @@ export class ProposeCantoPage implements OnInit {
constructor(private toastController: ToastController, private popoverController: PopoverController) { } constructor(private toastController: ToastController, private popoverController: PopoverController) { }
ngOnInit() { ngOnInit() {
this.route.queryParams.subscribe(params => {
const editId = params['editId'];
if (editId) {
this.editId = editId;
// Find the song in standard canti or personal canti list
const song = [
...this.cantiService.canti(),
...this.myCantiService.myCanti()
].find(c => c.id === editId);
if (song) {
this.title = song.titolo;
this.author = song.autore || '';
this.youtubeLink = song.link_youtube || '';
this.content = song.accordi || song.testo || '';
// Pre-populate liturgico and tematico lists
const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
const temIds = this.cantiService.indiceTematico().map(m => m.id);
this.selectedLiturgico = song.id_momenti?.filter(id => litIds.includes(id)) || [];
this.selectedTematico = song.id_momenti?.filter(id => temIds.includes(id)) || [];
}
}
});
} }
async insertText(tag: string) { async insertText(tag: string) {
@@ -143,13 +172,70 @@ export class ProposeCantoPage implements OnInit {
this.cameraInput.nativeElement.click(); this.cameraInput.nativeElement.click();
} }
onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Mostra l'overlay solo se si sta trascinando un file
if (event.dataTransfer?.types.includes('Files')) {
this.isDraggingOver = true;
}
}
onDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
}
async onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0];
if (file.type.indexOf('image') !== -1) {
await this.processImageFile(file);
} else {
const toast = await this.toastController.create({
message: 'Per favore, trascina un file immagine valido.',
duration: 3000,
color: 'warning'
});
toast.present();
}
}
}
async onFileSelected(event: any, isCamera: boolean) { async onFileSelected(event: any, isCamera: boolean) {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) { if (!file) {
console.log('[OCR-Capture] Nessun file selezionato.'); console.log('[OCR-Capture] Nessun file selezionato.');
return; return;
} }
await this.processImageFile(file);
event.target.value = '';
}
async onPaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
event.preventDefault(); // Prevent pasting the image representation as text
const blob = items[i].getAsFile();
if (blob) {
const file = new File([blob], 'pasted-image.png', { type: blob.type });
await this.processImageFile(file);
}
break;
}
}
}
async processImageFile(file: File) {
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`); console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
this.isProcessingOCR = true; this.isProcessingOCR = true;
@@ -187,7 +273,6 @@ export class ProposeCantoPage implements OnInit {
} finally { } finally {
this.isProcessingOCR = false; this.isProcessingOCR = false;
this.ocrProgress = 0; this.ocrProgress = 0;
event.target.value = '';
} }
} }
@@ -293,7 +378,7 @@ export class ProposeCantoPage implements OnInit {
// Calculate average word height to set vertical tolerance // Calculate average word height to set vertical tolerance
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0); const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length; const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 0.6; const verticalTolerance = avgHeight * 0.85;
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`); console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
// 2. Group words into horizontal lines // 2. Group words into horizontal lines
@@ -324,16 +409,43 @@ export class ProposeCantoPage implements OnInit {
}); });
// 3. Classify lines as Chords vs. Text // 3. Classify lines as Chords vs. Text
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?$/i; const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
const isChordWord = (text: string): boolean => { const isChordWord = (text: string): boolean => {
const clean = text.replace(/[\[\]\(\)\.\,\-\+]/g, '').trim().toUpperCase(); if (!text || text.length === 0) return false;
const lower = text.toLowerCase();
// Skip common valid words written purely in lowercase
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
return false;
}
let clean = text.toUpperCase().replace(/\s+/g, '');
clean = clean.replace(/\((.*?)\)/g, '/$1');
clean = clean.replace(/[\.\,]$/g, '');
return chordRegex.test(clean); return chordRegex.test(clean);
}; };
const classifiedLines = lines.map(line => { const classifiedLines = lines.map(line => {
const chordCount = line.filter(w => isChordWord(w.text)).length; let chordCount = 0;
let hasLongNonChord = false;
line.forEach(w => {
if (isChordWord(w.text)) {
chordCount++;
} else {
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
if (clean.length > 5) {
hasLongNonChord = true;
}
}
});
const ratio = line.length > 0 ? chordCount / line.length : 0; const ratio = line.length > 0 ? chordCount / line.length : 0;
const isChords = ratio >= 0.4 && line.length <= 10; let isChords = false;
if (ratio >= 0.4 && line.length <= 10) {
if (!hasLongNonChord || ratio >= 0.75) {
isChords = true;
}
}
return { return {
words: line, words: line,
@@ -371,8 +483,29 @@ export class ProposeCantoPage implements OnInit {
i++; // Skip next line because we consumed it! i++; // Skip next line because we consumed it!
} else { } else {
// Chord line but no text below it: just wrap and print // Chord line but no text below it: just wrap and print
const wrapped = current.words.map(w => `[${w.text.replace(/[\(\)\[\]]/g, '').toUpperCase()}]`).join(' '); const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
processedLines.push(wrapped); const expandedChords: string[] = [];
current.words.forEach(w => {
let cleanText = w.text.toUpperCase().replace(/\s+/g, '');
cleanText = cleanText.replace(/\((.*?)\)/g, '/$1');
cleanText = cleanText.replace(/[\.\,]$/g, '');
if (chordRegex.test(cleanText)) {
expandedChords.push(`[${cleanText}]`);
} else {
const matches = [...cleanText.matchAll(multiChordRegex)];
const fullMatchStr = matches.map(m => m[0]).join('');
if (matches.length > 0 && fullMatchStr === cleanText) {
expandedChords.push(...matches.map((m: string) => `[${m[0].toUpperCase()}]`));
} else {
expandedChords.push(w.text); // keep original text if it's not a pure chord merge
}
}
});
const wrapped = expandedChords.join(' ');
if (wrapped) {
processedLines.push(wrapped);
}
} }
} else { } else {
const lineText = current.words.map(w => w.text).join(' '); const lineText = current.words.map(w => w.text).join(' ');
@@ -408,17 +541,106 @@ export class ProposeCantoPage implements OnInit {
} }
mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string { mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
let result = ''; // Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
let mergedChordWords: any[] = [];
for (let i = 0; i < chordWords.length; i++) {
let cw = chordWords[i];
if (cw.text.startsWith('(') && mergedChordWords.length > 0) {
let prev = mergedChordWords[mergedChordWords.length - 1];
prev.text += cw.text;
prev.bbox.x1 = Math.max(prev.bbox.x1, cw.bbox.x1);
if (!prev.text.includes(')')) {
let j = i + 1;
while (j < chordWords.length) {
prev.text += chordWords[j].text;
prev.bbox.x1 = Math.max(prev.bbox.x1, chordWords[j].bbox.x1);
if (chordWords[j].text.includes(')')) {
i = j;
break;
}
j++;
}
}
} else {
let text = cw.text;
let bbox = { ...cw.bbox };
if (text.includes('(') && !text.includes(')')) {
let j = i + 1;
while (j < chordWords.length) {
text += chordWords[j].text;
bbox.x1 = Math.max(bbox.x1, chordWords[j].bbox.x1);
if (chordWords[j].text.includes(')')) {
i = j;
break;
}
j++;
}
}
mergedChordWords.push({ text, bbox });
}
}
// Sanitize common OCR errors in chords (e.g., 'Re(f fa#)' -> 'Re(fa#)')
mergedChordWords.forEach(cw => {
cw.text = cw.text.replace(/f\s*fa#/gi, 'fa#');
cw.text = cw.text.replace(/ff/gi, 'f');
cw.text = cw.text.replace(/m\s*mi/gi, 'mi');
cw.text = cw.text.replace(/mm/gi, 'm');
});
if (!textWords || textWords.length === 0) {
return mergedChordWords.map(c => {
let clean = c.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
return `[${clean}]`;
}).join(' ');
}
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
const expandedChordWords: any[] = [];
mergedChordWords.forEach(chord => {
let originalText = chord.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
const matches = [...originalText.matchAll(multiChordRegex)];
if (matches.length === 0) {
expandedChordWords.push(chord);
} else {
const fullMatchStr = matches.map(m => m[0]).join('');
if (fullMatchStr === originalText) {
const charWidth = (chord.bbox.x1 - chord.bbox.x0) / Math.max(1, originalText.length);
matches.forEach(match => {
const matchIndex = match.index!;
const matchLength = match[0].length;
const newX0 = chord.bbox.x0 + matchIndex * charWidth;
const newX1 = chord.bbox.x0 + (matchIndex + matchLength) * charWidth;
expandedChordWords.push({
text: match[0],
bbox: { ...chord.bbox, x0: newX0, x1: newX1 }
});
});
} else {
expandedChordWords.push(chord);
}
}
});
const chordAssignments = new Map<any, any[]>(); const chordAssignments = new Map<any, any[]>();
chordWords.forEach(chord => { expandedChordWords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2; const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let closestWord: any = null; let closestWord: any = null;
let minDistance = Infinity; let minDistance = Infinity;
textWords.forEach(textWord => { textWords.forEach(textWord => {
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2; let dist = 0;
const dist = Math.abs(chordX - wordXCenter); if (chordX < textWord.bbox.x0) {
dist = textWord.bbox.x0 - chordX;
} else if (chordX > textWord.bbox.x1) {
dist = chordX - textWord.bbox.x1;
}
if (dist < minDistance) { if (dist < minDistance) {
minDistance = dist; minDistance = dist;
closestWord = textWord; closestWord = textWord;
@@ -433,22 +655,44 @@ export class ProposeCantoPage implements OnInit {
} }
}); });
let result = '';
textWords.forEach((textWord, index) => { textWords.forEach((textWord, index) => {
const assignedChords = chordAssignments.get(textWord) || []; const assignedChords = chordAssignments.get(textWord) || [];
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0); assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
const wordText = textWord.text;
let charWidth = (textWord.bbox.x1 - textWord.bbox.x0) / Math.max(1, wordText.length);
if (charWidth <= 0) charWidth = 6; // safe fallback
let lastCharIndex = 0;
let wordResult = '';
assignedChords.forEach(chord => { assignedChords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let charIndex = Math.round((chordX - textWord.bbox.x0) / charWidth);
if (charIndex < 0) charIndex = 0;
if (charIndex > wordText.length) charIndex = wordText.length;
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase(); const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
result += `[${cleanChord}]`; wordResult += wordText.substring(lastCharIndex, charIndex) + `[${cleanChord}]`;
lastCharIndex = charIndex;
}); });
result += textWord.text; wordResult += wordText.substring(lastCharIndex);
result += wordResult;
if (index < textWords.length - 1) { if (index < textWords.length - 1) {
result += ' '; result += ' ';
} }
}); });
return result; const finalResult = result.replace(/\]\[/g, '] [');
console.log(`[OCR-Debug] Linea generata: ${finalResult}`);
// Assicura che due accordi consecutivi abbiano sempre 3 spazi (es. [LA][MI] diventa [LA] [MI])
return finalResult;
} }
@@ -506,6 +750,7 @@ export class ProposeCantoPage implements OnInit {
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico]; const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
await this.myCantiService.saveCanto({ await this.myCantiService.saveCanto({
id: this.editId || undefined,
titolo: this.title, titolo: this.title,
autore: this.author, autore: this.author,
link_youtube: this.youtubeLink, link_youtube: this.youtubeLink,
+8 -8
View File
@@ -127,12 +127,12 @@
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle> <ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
</ion-item> </ion-item>
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
<ion-icon name="mic-outline" slot="start" color="secondary"></ion-icon> <ion-icon name="eye-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font"> <ion-label class="outfit-font">
<h2 class="settings-item-title">Autoscroll acustico</h2> <h2 class="settings-item-title">Autoscroll visuale</h2>
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p> <p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
</ion-label> </ion-label>
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle> <ion-toggle slot="end" [checked]="settingsService.enableVisualAutoscroll()" (ionChange)="settingsService.toggleVisualAutoscroll()" color="secondary"></ion-toggle>
</ion-item> </ion-item>
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);"> <ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon> <ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
@@ -275,12 +275,12 @@
<div class="legend-list"> <div class="legend-list">
<div class="legend-item"> <div class="legend-item">
<div class="legend-icon-wrapper"> <div class="legend-icon-wrapper">
<ion-icon name="mic" color="danger"></ion-icon> <ion-icon name="videocam" color="success"></ion-icon>
</div> </div>
<div class="legend-text"> <div class="legend-text">
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4> <h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
<p class="outfit-font"> <p class="outfit-font">
Attiva lo scorrimento vocale intelligente. L'app ascolta il canto o lo strumento e fa scorrere testo e accordi a tempo di musica, senza bisogno di toccare lo schermo. Uno slider verticale permette di regolare la sensibilità. Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo.
</p> </p>
</div> </div>
</div> </div>
@@ -321,7 +321,7 @@
<div class="legend-text"> <div class="legend-text">
<h4 class="outfit-font">Riavvia Canto</h4> <h4 class="outfit-font">Riavvia Canto</h4>
<p class="outfit-font"> <p class="outfit-font">
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento vocale dello scroll acustico. Riporta la visualizzazione all'inizio del testo e azzera il tracciamento dello scroll visuale.
</p> </p>
</div> </div>
</div> </div>
+104 -90
View File
@@ -14,6 +14,7 @@ import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service'; import { CantiLettureService } from '../../services/canti-letture.service';
import { ComunitaService } from '../../services/comunita.service'; import { ComunitaService } from '../../services/comunita.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { showFullscreenUpdateOverlay } from '../../app.component';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@@ -43,26 +44,6 @@ export class SettingsPage {
constructor() {} constructor() {}
private async forceBypassCacheAndCheck(): Promise<boolean> {
try {
// 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.update();
console.log('[PWA-Update] Native Service Worker updated from settings');
}
}
// 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali
await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' });
await fetch('/ngsw.json', { cache: 'reload' });
console.log('[PWA-Update] Caches successfully busted for ngsw.json');
} catch (e) {
console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e);
}
return await this.swUpdate.checkForUpdate();
}
async installApp() { async installApp() {
await this.settingsService.installPwa(); await this.settingsService.installPwa();
} }
@@ -79,6 +60,10 @@ export class SettingsPage {
this.cantiLettureService.setSelectedMass(event.detail.value); this.cantiLettureService.setSelectedMass(event.detail.value);
} }
/**
* Performs a full data refresh + checks for app updates.
* Uses both the Angular SW and the version.json fallback.
*/
async fullRefresh() { async fullRefresh() {
// 1. Refresh JSON data // 1. Refresh JSON data
this.cantiService.refresh(); this.cantiService.refresh();
@@ -100,40 +85,10 @@ export class SettingsPage {
} }
} }
// 4. Check for Service Worker updates // 4. Check for app updates (SW + version.json fallback)
if (this.swUpdate.isEnabled) { const updateAvailable = await this.performUpdateCheck();
try { if (updateAvailable) {
const updateFound = await this.forceBypassCacheAndCheck(); return; // updateAvailable already triggered the update/reload flow
if (updateFound) {
const toast = await this.toastCtrl.create({
message: 'Nuova versione disponibile! Aggiornamento in corso...',
duration: 2000,
color: 'secondary'
});
await toast.present();
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(async () => {
console.log('[PWA-Update] FullRefresh: version ready, activating...');
await this.swUpdate.activateUpdate();
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
});
setTimeout(async () => {
try {
await this.swUpdate.activateUpdate();
} catch(e) {}
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 6000);
return;
}
} catch (err) {
console.error('Failed to check for updates', err);
}
} }
const toast = await this.toastCtrl.create({ const toast = await this.toastCtrl.create({
@@ -162,51 +117,110 @@ export class SettingsPage {
}); });
await toastLoading.present(); await toastLoading.present();
const updateAvailable = await this.performUpdateCheck();
if (!updateAvailable) {
const toast = await this.toastCtrl.create({
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
duration: 3000,
color: 'success'
});
await toast.present();
}
}
/**
* Shared update check logic: tries Angular SW first, falls back to version.json.
* Returns true if an update was found and the reload flow was initiated.
*/
private async performUpdateCheck(): Promise<boolean> {
try { try {
const updateFound = await this.forceBypassCacheAndCheck(); // Layer 1: Force the browser to re-fetch the SW script
if (updateFound) { if ('serviceWorker' in navigator) {
const toast = await this.toastCtrl.create({ const registration = await navigator.serviceWorker.ready;
message: 'Nuova versione trovata! Installazione e attivazione in corso...', await registration.update();
duration: 3000,
color: 'success'
});
await toast.present();
// Sottoscrizione per attivare l'aggiornamento appena terminato il download
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(async () => {
console.log('[PWA-Update] Manual check: version ready, activating...');
await this.swUpdate.activateUpdate();
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
});
// Timeout di sicurezza per forzare l'attivazione e il ricaricamento se è già scaricato
setTimeout(async () => {
try {
await this.swUpdate.activateUpdate();
} catch(e) {}
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 8000);
} else {
const toast = await this.toastCtrl.create({
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
duration: 3000,
color: 'success'
});
await toast.present();
} }
// Layer 2: Ask Angular SW to check
if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate();
if (swFoundUpdate) {
await this.applyUpdateAndReload();
return true;
}
}
// Layer 3: Fallback — check version.json
const versionMismatch = await this.checkVersionJson();
if (versionMismatch) {
console.log('[PWA-Update] version.json mismatch detected from settings');
await this.applyUpdateAndReload();
return true;
}
return false;
} catch (err) { } catch (err) {
console.error('Check update failed', err); console.error('[PWA-Update] Update check failed from settings:', err);
const toast = await this.toastCtrl.create({ const toast = await this.toastCtrl.create({
message: 'Errore durante la ricerca di aggiornamenti.', message: 'Errore durante la ricerca di aggiornamenti.',
duration: 3000, duration: 3000,
color: 'danger' color: 'danger'
}); });
await toast.present(); await toast.present();
return false;
}
}
private async applyUpdateAndReload() {
const overlay = showFullscreenUpdateOverlay();
let activated = false;
const activateAndReload = async () => {
if (activated) return;
activated = true;
try {
if (this.swUpdate.isEnabled) {
await this.swUpdate.activateUpdate();
}
} catch (e) {
console.warn('[PWA-Update] activateUpdate failed:', e);
}
overlay.finish();
setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 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();
});
}
// Safety timeout: reload after 6s regardless
setTimeout(() => {
console.log('[PWA-Update] Settings: safety timeout reached, activating...');
activateAndReload();
}, 6000);
}
private async checkVersionJson(): Promise<boolean> {
try {
const response = await fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) return false;
const data = await response.json();
console.log(`[PWA-Update] Settings version check: local=${VERSION}, remote=${data.version}`);
return data.version !== VERSION;
} catch (err) {
console.warn('[PWA-Update] version.json check failed:', err);
return false;
} }
} }
} }
+51 -12
View File
@@ -32,20 +32,57 @@ export class MyCantiService {
} }
} }
async saveCanto(canto: Partial<Canto>) { async saveCanto(canto: Partial<Canto>): Promise<Canto> {
const current = this.myCanti(); const current = this.myCanti();
const newCanto: Canto = { let updated: Canto[];
id: `my_${Date.now()}`, let targetCanto: Canto;
id_canti: Date.now(), // Fake ID for internal logic
titolo: canto.titolo || 'Senza Titolo', if (canto.id && canto.id.startsWith('my_')) {
testo: canto.testo || '', // Update existing song
accordi: canto.accordi, updated = current.map(c => {
autore: canto.autore, if (c.id === canto.id) {
link_youtube: canto.link_youtube, targetCanto = {
id_momenti: canto.id_momenti || [] ...c,
}; titolo: canto.titolo || c.titolo,
testo: canto.testo || c.testo,
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
autore: canto.autore !== undefined ? canto.autore : c.autore,
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube,
id_momenti: canto.id_momenti || c.id_momenti
};
return targetCanto;
}
return c;
});
// Fallback if not found in list (should not happen normally)
if (!updated.some(c => c.id === canto.id)) {
targetCanto = {
id: canto.id,
id_canti: canto.id_canti || Date.now(),
titolo: canto.titolo || 'Senza Titolo',
testo: canto.testo || '',
accordi: canto.accordi,
autore: canto.autore,
link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || []
};
updated.push(targetCanto);
}
} else {
// Create new song
targetCanto = {
id: `my_${Date.now()}`,
id_canti: Date.now(), // Fake ID for internal logic
titolo: canto.titolo || 'Senza Titolo',
testo: canto.testo || '',
accordi: canto.accordi,
autore: canto.autore,
link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || []
};
updated = [...current, targetCanto];
}
const updated = [...current, newCanto];
this.myCanti.set(updated); this.myCanti.set(updated);
await this._storage?.set('my-canti', updated); await this._storage?.set('my-canti', updated);
@@ -55,6 +92,8 @@ export class MyCantiService {
color: 'success' color: 'success'
}); });
toast.present(); toast.present();
return targetCanto!;
} }
async deleteCanto(id: string) { async deleteCanto(id: string) {
+16 -39
View File
@@ -42,10 +42,10 @@ export class SettingsService {
public showUpdateDate = signal<boolean>(true); public showUpdateDate = signal<boolean>(true);
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */ /** Attiva autoscroll standard nel dettaglio canto: true = attivo */
public enableStandardAutoscroll = signal<boolean>(true); public enableStandardAutoscroll = signal<boolean>(false);
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */ /** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
public enableAcousticAutoscroll = signal<boolean>(false); public enableVisualAutoscroll = signal<boolean>(true);
/** Preferenza notazione accordi: diesis o bemolle */ /** Preferenza notazione accordi: diesis o bemolle */
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
@@ -96,7 +96,7 @@ export class SettingsService {
}); });
// Migration: force settings defaults once for existing users to match the new rules // Migration: force settings defaults once for existing users to match the new rules
const migrationKey = 'defaults-migrated-20260521'; const migrationKey = 'defaults-migrated-20260605';
if (localStorage.getItem(migrationKey) !== 'true') { if (localStorage.getItem(migrationKey) !== 'true') {
localStorage.setItem('show-chords-default', 'true'); localStorage.setItem('show-chords-default', 'true');
localStorage.setItem('fullscreen-mode', this.isIos().toString()); localStorage.setItem('fullscreen-mode', this.isIos().toString());
@@ -107,8 +107,8 @@ export class SettingsService {
localStorage.setItem('invio-dati-statistici', 'false'); localStorage.setItem('invio-dati-statistici', 'false');
localStorage.setItem('show-tags-in-list', 'true'); localStorage.setItem('show-tags-in-list', 'true');
localStorage.setItem('show-update-date', 'true'); localStorage.setItem('show-update-date', 'true');
localStorage.setItem('enable-standard-autoscroll', 'true'); localStorage.setItem('enable-standard-autoscroll', 'false');
localStorage.setItem('enable-acoustic-autoscroll', 'false'); localStorage.setItem('enable-visual-autoscroll', 'true');
localStorage.setItem('chord-notation-preference', 'diesis'); localStorage.setItem('chord-notation-preference', 'diesis');
// ThemeService high contrast default // ThemeService high contrast default
@@ -184,14 +184,14 @@ export class SettingsService {
if (savedStandardAutoscroll !== null) { if (savedStandardAutoscroll !== null) {
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true'); this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
} else { } else {
this.enableStandardAutoscroll.set(true); this.enableStandardAutoscroll.set(false);
} }
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll'); const savedVisualAutoscroll = localStorage.getItem('enable-visual-autoscroll');
if (savedAcousticAutoscroll !== null) { if (savedVisualAutoscroll !== null) {
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true'); this.enableVisualAutoscroll.set(savedVisualAutoscroll === 'true');
} else { } else {
this.enableAcousticAutoscroll.set(false); this.enableVisualAutoscroll.set(true);
} }
const savedNotation = localStorage.getItem('chord-notation-preference'); const savedNotation = localStorage.getItem('chord-notation-preference');
@@ -231,29 +231,6 @@ export class SettingsService {
effect(() => { effect(() => {
const mode = this.fullscreenMode(); const mode = this.fullscreenMode();
localStorage.setItem('fullscreen-mode', mode.toString()); localStorage.setItem('fullscreen-mode', mode.toString());
const isFs = !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
);
if (mode && !isFs) {
const docEl = document.documentElement as any;
if (docEl.requestFullscreen) {
docEl.requestFullscreen().catch((err: any) => console.log('Request fs ignored', err));
} else if (docEl.webkitRequestFullscreen) {
docEl.webkitRequestFullscreen();
}
} else if (!mode && isFs) {
const doc = document as any;
if (doc.exitFullscreen) {
doc.exitFullscreen().catch((err: any) => console.log('Exit fs ignored', err));
} else if (doc.webkitExitFullscreen) {
doc.webkitExitFullscreen();
}
}
}); });
effect(() => { effect(() => {
@@ -273,7 +250,7 @@ export class SettingsService {
}); });
effect(() => { effect(() => {
localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString()); localStorage.setItem('enable-visual-autoscroll', this.enableVisualAutoscroll().toString());
}); });
effect(() => { effect(() => {
@@ -404,10 +381,10 @@ export class SettingsService {
localStorage.setItem('enable-standard-autoscroll', newValue.toString()); localStorage.setItem('enable-standard-autoscroll', newValue.toString());
} }
toggleAcousticAutoscroll() { toggleVisualAutoscroll() {
const newValue = !this.enableAcousticAutoscroll(); const newValue = !this.enableVisualAutoscroll();
this.enableAcousticAutoscroll.set(newValue); this.enableVisualAutoscroll.set(newValue);
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString()); localStorage.setItem('enable-visual-autoscroll', newValue.toString());
} }
toggleKaraokePageScrollMode() { toggleKaraokePageScrollMode() {
+38 -4
View File
@@ -6,6 +6,9 @@ import { Injectable, signal, effect } from '@angular/core';
export class ThemeService { export class ThemeService {
public highContrast = signal<boolean>(true); public highContrast = signal<boolean>(true);
/** Whether the system prefers dark mode */
private systemPrefersDark = signal<boolean>(false);
constructor() { constructor() {
// Load from localStorage // Load from localStorage
const saved = localStorage.getItem('high-contrast'); const saved = localStorage.getItem('high-contrast');
@@ -15,18 +18,49 @@ export class ThemeService {
this.highContrast.set(true); this.highContrast.set(true);
} }
// Effect to apply class to body // Detect system dark mode preference
if (typeof window !== 'undefined') {
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
this.systemPrefersDark.set(darkMq.matches);
darkMq.addEventListener('change', (e) => {
this.systemPrefersDark.set(e.matches);
});
}
// Effect to apply high-contrast class to body and html
effect(() => { effect(() => {
const isHigh = this.highContrast(); const isHigh = this.highContrast();
if (typeof document !== 'undefined' && document.body) { if (typeof document !== 'undefined') {
const root = document.documentElement;
if (isHigh) { if (isHigh) {
document.body.classList.add('high-contrast'); root.classList.add('high-contrast');
if (document.body) document.body.classList.add('high-contrast');
} else { } else {
document.body.classList.remove('high-contrast'); root.classList.remove('high-contrast');
if (document.body) document.body.classList.remove('high-contrast');
} }
} }
localStorage.setItem('high-contrast', isHigh.toString()); localStorage.setItem('high-contrast', isHigh.toString());
}); });
// Effect to manage Ionic dark palette class
// When high contrast is ON → NEVER apply dark palette (force light mode)
// When high contrast is OFF and system prefers dark → apply dark palette
effect(() => {
const isHigh = this.highContrast();
const systemDark = this.systemPrefersDark();
if (typeof document !== 'undefined') {
const root = document.documentElement;
if (!isHigh && systemDark) {
root.classList.add('ion-palette-dark');
if (document.body) document.body.classList.add('ion-palette-dark');
} else {
root.classList.remove('ion-palette-dark');
if (document.body) document.body.classList.remove('ion-palette-dark');
}
}
});
} }
toggleContrast() { toggleContrast() {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.06.04.1906'; export const VERSION = '2026.06.06.0038';
+126 -3
View File
@@ -33,8 +33,8 @@
*/ */
/* @import "@ionic/angular/css/palettes/dark.always.css"; */ /* @import "@ionic/angular/css/palettes/dark.always.css"; */
/* @import "@ionic/angular/css/palettes/dark.class.css"; */ /* @import "@ionic/angular/css/palettes/dark.system.css"; */
@import "@ionic/angular/css/palettes/dark.system.css"; @import "@ionic/angular/css/palettes/dark.class.css";
ion-header { ion-header {
border: none !important; border: none !important;
@@ -66,14 +66,124 @@ ion-app {
} }
/* High Contrast Mode Overrides */ /* High Contrast Mode Overrides */
html.high-contrast, body.high-contrast {
color-scheme: light !important;
}
body.high-contrast { body.high-contrast {
--ion-background-color: #ffffff; --ion-background-color: #ffffff;
--ion-background-color-rgb: 255, 255, 255; --ion-background-color-rgb: 255, 255, 255;
--ion-text-color: #000000; --ion-text-color: #000000;
--ion-text-color-rgb: 0, 0, 0; --ion-text-color-rgb: 0, 0, 0;
/* Primary color - complete set for shadow DOM components */
--ion-color-primary: #000000; --ion-color-primary: #000000;
--ion-color-secondary: #e67e22; // A bit darker for readability on white --ion-color-primary-rgb: 0, 0, 0;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #000000;
--ion-color-primary-tint: #1a1a1a;
/* Secondary color - complete set for shadow DOM components */
--ion-color-secondary: #e67e22;
--ion-color-secondary-rgb: 230, 126, 34;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #cb6f1e;
--ion-color-secondary-tint: #e98b38;
/* Medium color - used by filter chips */
--ion-color-medium: #92949c;
--ion-color-medium-rgb: 146, 148, 156;
--ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #808289;
--ion-color-medium-tint: #9d9fa6;
/* Light color */
--ion-color-light: #f4f5f8;
--ion-color-light-rgb: 244, 245, 248;
--ion-color-light-contrast: #000000;
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
/* Dark color */
--ion-color-dark: #222428;
--ion-color-dark-rgb: 34, 36, 40;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
/* Light mode background step variables (light → dark) */
--ion-background-color-step-50: #f2f2f2;
--ion-background-color-step-100: #e6e6e6;
--ion-background-color-step-150: #d9d9d9;
--ion-background-color-step-200: #cccccc;
--ion-background-color-step-250: #bfbfbf;
--ion-background-color-step-300: #b3b3b3;
--ion-background-color-step-350: #a6a6a6;
--ion-background-color-step-400: #999999;
--ion-background-color-step-450: #8c8c8c;
--ion-background-color-step-500: #808080;
--ion-background-color-step-550: #737373;
--ion-background-color-step-600: #666666;
--ion-background-color-step-650: #595959;
--ion-background-color-step-700: #4d4d4d;
--ion-background-color-step-750: #404040;
--ion-background-color-step-800: #333333;
--ion-background-color-step-850: #262626;
--ion-background-color-step-900: #1a1a1a;
--ion-background-color-step-950: #0d0d0d;
/* Light mode text step variables (dark → light) */
--ion-text-color-step-50: #0d0d0d;
--ion-text-color-step-100: #1a1a1a;
--ion-text-color-step-150: #262626;
--ion-text-color-step-200: #333333;
--ion-text-color-step-250: #404040;
--ion-text-color-step-300: #4d4d4d;
--ion-text-color-step-350: #595959;
--ion-text-color-step-400: #666666;
--ion-text-color-step-450: #737373;
--ion-text-color-step-500: #808080;
--ion-text-color-step-550: #8c8c8c;
--ion-text-color-step-600: #999999;
--ion-text-color-step-650: #a6a6a6;
--ion-text-color-step-700: #b3b3b3;
--ion-text-color-step-750: #bfbfbf;
--ion-text-color-step-800: #cccccc;
--ion-text-color-step-850: #d9d9d9;
--ion-text-color-step-900: #e6e6e6;
--ion-text-color-step-950: #f2f2f2;
/* Legacy step variables (for older Ionic components) */
--ion-color-step-50: #f4f5f8;
--ion-color-step-100: #e0e0e0;
--ion-color-step-150: #dcdcdc;
--ion-color-step-200: #cccccc;
--ion-color-step-250: #bfbfbf;
--ion-color-step-300: #b3b3b3;
--ion-color-step-350: #a6a6a6;
--ion-color-step-400: #999999;
--ion-color-step-450: #8c8c8c;
--ion-color-step-500: #808080;
--ion-color-step-550: #737373;
--ion-color-step-600: #666666;
--ion-color-step-650: #595959;
--ion-color-step-700: #4d4d4d;
--ion-color-step-750: #404040;
--ion-color-step-800: #333333;
--ion-color-step-850: #262626;
--ion-color-step-900: #191919;
--ion-color-step-950: #0d0d0d;
/* Reset component-specific dark mode variables */
--ion-item-background: #ffffff;
--ion-card-background: #ffffff;
--ion-toolbar-background: #ffffff;
--ion-tab-bar-background: #ffffff;
.bg-gradient { .bg-gradient {
background: #ffffff !important; background: #ffffff !important;
@@ -263,6 +373,19 @@ body.high-contrast {
color: #000000 !important; color: #000000 !important;
} }
} }
/* Prevent button color/background issues in active/focus/hover states in high contrast */
ion-button {
--color-activated: var(--color) !important;
--color-focused: var(--color) !important;
--color-hover: var(--color) !important;
&[fill="clear"], &[fill="outline"] {
--background-activated: rgba(0, 0, 0, 0.1) !important;
--background-focused: rgba(0, 0, 0, 0.08) !important;
--background-hover: rgba(0, 0, 0, 0.05) !important;
}
}
} }
.offline-badge-header { .offline-badge-header {
+38 -1
View File
@@ -34,7 +34,44 @@
</head> </head>
<body> <body>
<app-root></app-root> <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%;">
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 10px; 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: 30px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dell'applicazione...</p>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div>
<div id="pwa-boot-percent" style="font-size: 1.2rem; font-weight: 700; color: #e67e22; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">0%</div>
</div>
</div>
<script>
(function() {
const isUpdate = window.location.search.includes('update');
const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc');
if (isUpdate && titleEl && descEl) {
titleEl.textContent = 'Aggiornamento completato';
descEl.textContent = 'Ottimizzazione e avvio della nuova versione...';
}
let percent = 0;
const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent');
const interval = setInterval(() => {
if (percent < 98) {
percent += Math.floor(Math.random() * 8) + 3;
if (percent > 98) percent = 98;
if (bar) bar.style.width = percent + '%';
if (pctText) pctText.textContent = percent + '%';
} else {
clearInterval(interval);
}
}, 80);
})();
</script>
</app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <noscript>Please enable JavaScript to continue using this application.</noscript>
</body> </body>