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"
# --- 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
echo "📁 Build pronta nella root..."
# Nessuna sottocartella ionic necessaria in locale
+5
View File
@@ -51,5 +51,10 @@ if [ ! -d "www" ]; then
exit 1
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 ---
python3 scratch/deploy_ftp.py
+1 -1
View File
@@ -11,7 +11,7 @@
<IfModule mod_headers.c>
# 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 Pragma "no-cache"
Header set Expires 0
+60 -123
View File
@@ -1,8 +1,5 @@
import { Component, inject, ApplicationRef } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators';
import { concat, interval, fromEvent } from 'rxjs';
import { ToastController } from '@ionic/angular';
import { Component, inject } from '@angular/core';
import { ThemeService } from './services/theme.service';
@Component({
selector: 'app-root',
@@ -11,125 +8,65 @@ import { ToastController } from '@ionic/angular';
standalone: false,
})
export class AppComponent {
private swUpdate = inject(SwUpdate);
private appRef = inject(ApplicationRef);
private toastCtrl = inject(ToastController);
private themeService = inject(ThemeService); // Ensures theme is initialized at boot
constructor() {
this.setupUpdates();
}
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);
}
});
}
// Gli aggiornamenti automatici e periodici sono stati rimossi.
// L'aggiornamento viene gestito esclusivamente in modo manuale
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
}
}
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">
<div class="header-text-group">
<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>
</ion-title>
@@ -283,7 +286,7 @@
<!-- Selection Area -->
<div class="selection-column" (click)="playlistService.toggleSongSelection(canto.id); $event.stopPropagation()">
<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>
</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 { ComunitaService } from '../services/comunita.service';
import { environment } from '../../environments/environment';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators';
import { showFullscreenUpdateOverlay } from '../app.component';
@Component({
selector: 'app-home',
@@ -67,9 +70,123 @@ export class HomePage implements OnDestroy {
private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController);
private loadingCtrl = inject(LoadingController);
private swUpdate = inject(SwUpdate);
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() {
if (this.firstInteraction) {
this.firstInteraction = false;
@@ -1104,6 +1221,12 @@ export class HomePage implements OnDestroy {
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() {
if (!this.comunitaService.comunitaCode()) {
this.promptComunitaCode();
+3
View File
@@ -74,6 +74,9 @@
.seg-text {
white-space: pre-wrap;
&::after {
content: '\200b';
}
}
.footer-info {
+5 -2
View File
@@ -6,7 +6,7 @@
<ion-title class="outfit-font wrapped-title">
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
<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 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>
@@ -26,6 +26,9 @@
<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-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-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
@@ -153,7 +156,7 @@
</div>
<!-- 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-button>
+17
View File
@@ -197,6 +197,9 @@
.seg-text {
white-space: pre;
&::after {
content: '\200b';
}
}
}
@@ -634,6 +637,20 @@ ion-content.full-screen-content {
}
: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 {
background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
+86 -21
View File
@@ -412,18 +412,29 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const container = document.querySelector('.lyrics-container') as HTMLElement;
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);
let visibleBottom = Math.min(containerRect.bottom, window.innerHeight || document.documentElement.clientHeight);
// Calcoliamo la reale altezza visibile sottraendo l'eventuale footer in sovraimpressione
let visibleHeight = containerHeight;
const footer = document.querySelector('ion-footer') as HTMLElement;
if (footer && footer.offsetHeight > 0) {
const computedStyle = window.getComputedStyle(footer);
if (computedStyle.display !== 'none') {
visibleHeight -= footer.offsetHeight;
if (footer) {
const footerRect = footer.getBoundingClientRect();
if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') {
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');
if (lineElems.length === 0) return 3;
@@ -443,7 +454,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const pageStep = Math.max(1, linesPerPage);
console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', {
containerHeight,
visibleHeight,
avgLineHeight,
linesPerPage,
@@ -463,10 +473,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (!container) return [];
const containerRect = container.getBoundingClientRect();
const visibleTop = containerRect.top;
let visibleBottom = containerRect.bottom;
const visibleTop = Math.max(containerRect.top, 0);
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;
if (footer) {
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 visibleIndices: number[] = [];
@@ -501,7 +519,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (!container) return this.currentLineIndex() + 1;
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
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 visibleLimitY = visibleBottom - 8;
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);
}
}
// 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 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();
const totalLines = this.getTotalLines();
@@ -571,8 +600,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
nextIdx = this.calculateNextPageStartIndex();
}
this.lastScrollBlock.set('center');
} else if (this.settingsService.karaokePageScrollMode()) {
// Modalità manuale a pagine: calcolo analitico preciso per non perdere righe coperte
} else if (this.settingsService.karaokePageScrollMode() || isVisual) {
// Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte
nextIdx = this.calculateNextPageStartIndex();
this.lastScrollBlock.set('start');
} 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
prev() {
prev(isVisual: boolean = false) {
this.lastAdvanceTimestamp = Date.now();
const prevIdx = this.currentLineIndex();
if (prevIdx > 0) {
const isPageMode = this.settingsService.karaokePageScrollMode();
const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual;
const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
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) {
if (!this.youtubePlayerService.isPlayerSupported()) {
return;
@@ -757,6 +816,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
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() {
if (this.isAutoscrolling()) {
this.stopAutoscroll();
@@ -845,9 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
await this.faceDetector.start(videoEl, (direction) => {
console.log(`[PlayerPage] Head tilt trigger received: ${direction}`);
if (direction === 'next') {
this.next(false);
this.next(false, true);
} else {
this.prev();
this.prev(true);
}
});
} catch (e) {
+1 -1
View File
@@ -24,7 +24,7 @@
<ion-icon name="reorder-two-outline"></ion-icon>
</div>
<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 *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
</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));
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-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">
<!-- OCR Progress -->
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
@@ -101,7 +114,8 @@
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea">
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</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;
}
}
}
+260 -15
View File
@@ -6,13 +6,14 @@ import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({
selector: 'app-propose-canto',
templateUrl: './propose-canto.page.html',
styleUrls: ['./propose-canto.page.scss'],
standalone: true,
imports: [CommonModule, FormsModule, IonicModule]
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
})
export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
@@ -22,12 +23,14 @@ export class ProposeCantoPage implements OnInit {
private myCantiService = inject(MyCantiService);
private navCtrl = inject(NavController);
public themeService = inject(ThemeService);
private route = inject(ActivatedRoute);
title: string = '';
author: string = '';
youtubeLink: string = '';
selectedLiturgico: number[] = [];
selectedTematico: number[] = [];
editId: string | null = null;
private _content: string = '';
get content(): string { return this._content; }
@@ -41,6 +44,7 @@ export class ProposeCantoPage implements OnInit {
undoStack: string[] = [];
isProcessingOCR: boolean = false;
ocrProgress: number = 0;
isDraggingOver: boolean = false;
get isHighContrast(): boolean { return this.themeService.highContrast(); }
groupedChords = [
@@ -104,6 +108,31 @@ export class ProposeCantoPage implements OnInit {
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
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) {
@@ -143,13 +172,70 @@ export class ProposeCantoPage implements OnInit {
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) {
const file = event.target.files[0];
if (!file) {
console.log('[OCR-Capture] Nessun file selezionato.');
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`);
this.isProcessingOCR = true;
@@ -187,7 +273,6 @@ export class ProposeCantoPage implements OnInit {
} finally {
this.isProcessingOCR = false;
this.ocrProgress = 0;
event.target.value = '';
}
}
@@ -293,7 +378,7 @@ export class ProposeCantoPage implements OnInit {
// Calculate average word height to set vertical tolerance
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
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`);
// 2. Group words into horizontal lines
@@ -324,16 +409,43 @@ export class ProposeCantoPage implements OnInit {
});
// 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 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);
};
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 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 {
words: line,
@@ -371,9 +483,30 @@ export class ProposeCantoPage implements OnInit {
i++; // Skip next line because we consumed it!
} else {
// 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;
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 {
const lineText = current.words.map(w => w.text).join(' ');
const isChorus = chorusStartRegex.test(lineText);
@@ -408,17 +541,106 @@ export class ProposeCantoPage implements OnInit {
}
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[]>();
chordWords.forEach(chord => {
expandedChordWords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let closestWord: any = null;
let minDistance = Infinity;
textWords.forEach(textWord => {
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
const dist = Math.abs(chordX - wordXCenter);
let dist = 0;
if (chordX < textWord.bbox.x0) {
dist = textWord.bbox.x0 - chordX;
} else if (chordX > textWord.bbox.x1) {
dist = chordX - textWord.bbox.x1;
}
if (dist < minDistance) {
minDistance = dist;
closestWord = textWord;
@@ -433,22 +655,44 @@ export class ProposeCantoPage implements OnInit {
}
});
let result = '';
textWords.forEach((textWord, index) => {
const assignedChords = chordAssignments.get(textWord) || [];
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 => {
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();
result += `[${cleanChord}]`;
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${cleanChord}]`;
lastCharIndex = charIndex;
});
result += textWord.text;
wordResult += wordText.substring(lastCharIndex);
result += wordResult;
if (index < textWords.length - 1) {
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];
await this.myCantiService.saveCanto({
id: this.editId || undefined,
titolo: this.title,
autore: this.author,
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-item>
<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">
<h2 class="settings-item-title">Autoscroll acustico</h2>
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p>
<h2 class="settings-item-title">Autoscroll visuale</h2>
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
</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 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>
@@ -275,12 +275,12 @@
<div class="legend-list">
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="mic" color="danger"></ion-icon>
<ion-icon name="videocam" color="success"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4>
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
<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>
</div>
</div>
@@ -321,7 +321,7 @@
<div class="legend-text">
<h4 class="outfit-font">Riavvia Canto</h4>
<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>
</div>
</div>
+98 -84
View File
@@ -14,6 +14,7 @@ import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service';
import { ComunitaService } from '../../services/comunita.service';
import { environment } from '../../../environments/environment';
import { showFullscreenUpdateOverlay } from '../../app.component';
@Component({
selector: 'app-settings',
@@ -43,26 +44,6 @@ export class SettingsPage {
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() {
await this.settingsService.installPwa();
}
@@ -79,6 +60,10 @@ export class SettingsPage {
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() {
// 1. Refresh JSON data
this.cantiService.refresh();
@@ -100,40 +85,10 @@ export class SettingsPage {
}
}
// 4. Check for Service Worker updates
if (this.swUpdate.isEnabled) {
try {
const updateFound = await this.forceBypassCacheAndCheck();
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);
}
// 4. Check for app updates (SW + version.json fallback)
const updateAvailable = await this.performUpdateCheck();
if (updateAvailable) {
return; // updateAvailable already triggered the update/reload flow
}
const toast = await this.toastCtrl.create({
@@ -162,36 +117,9 @@ export class SettingsPage {
});
await toastLoading.present();
try {
const updateFound = await this.forceBypassCacheAndCheck();
if (updateFound) {
const toast = await this.toastCtrl.create({
message: 'Nuova versione trovata! Installazione e attivazione in corso...',
duration: 3000,
color: 'success'
});
await toast.present();
const updateAvailable = await this.performUpdateCheck();
// 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 {
if (!updateAvailable) {
const toast = await this.toastCtrl.create({
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
duration: 3000,
@@ -199,14 +127,100 @@ export class SettingsPage {
});
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 {
// Layer 1: Force the browser to re-fetch the SW script
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.update();
}
// 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) {
console.error('Check update failed', err);
console.error('[PWA-Update] Update check failed from settings:', 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);
};
// 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;
}
}
}
+42 -3
View File
@@ -32,9 +32,45 @@ export class MyCantiService {
}
}
async saveCanto(canto: Partial<Canto>) {
async saveCanto(canto: Partial<Canto>): Promise<Canto> {
const current = this.myCanti();
const newCanto: Canto = {
let updated: Canto[];
let targetCanto: Canto;
if (canto.id && canto.id.startsWith('my_')) {
// Update existing song
updated = current.map(c => {
if (c.id === canto.id) {
targetCanto = {
...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',
@@ -44,8 +80,9 @@ export class MyCantiService {
link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || []
};
updated = [...current, targetCanto];
}
const updated = [...current, newCanto];
this.myCanti.set(updated);
await this._storage?.set('my-canti', updated);
@@ -55,6 +92,8 @@ export class MyCantiService {
color: 'success'
});
toast.present();
return targetCanto!;
}
async deleteCanto(id: string) {
+16 -39
View File
@@ -42,10 +42,10 @@ export class SettingsService {
public showUpdateDate = signal<boolean>(true);
/** 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 */
public enableAcousticAutoscroll = signal<boolean>(false);
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
public enableVisualAutoscroll = signal<boolean>(true);
/** Preferenza notazione accordi: diesis o bemolle */
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
const migrationKey = 'defaults-migrated-20260521';
const migrationKey = 'defaults-migrated-20260605';
if (localStorage.getItem(migrationKey) !== 'true') {
localStorage.setItem('show-chords-default', 'true');
localStorage.setItem('fullscreen-mode', this.isIos().toString());
@@ -107,8 +107,8 @@ export class SettingsService {
localStorage.setItem('invio-dati-statistici', 'false');
localStorage.setItem('show-tags-in-list', 'true');
localStorage.setItem('show-update-date', 'true');
localStorage.setItem('enable-standard-autoscroll', 'true');
localStorage.setItem('enable-acoustic-autoscroll', 'false');
localStorage.setItem('enable-standard-autoscroll', 'false');
localStorage.setItem('enable-visual-autoscroll', 'true');
localStorage.setItem('chord-notation-preference', 'diesis');
// ThemeService high contrast default
@@ -184,14 +184,14 @@ export class SettingsService {
if (savedStandardAutoscroll !== null) {
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
} else {
this.enableStandardAutoscroll.set(true);
this.enableStandardAutoscroll.set(false);
}
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
if (savedAcousticAutoscroll !== null) {
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
const savedVisualAutoscroll = localStorage.getItem('enable-visual-autoscroll');
if (savedVisualAutoscroll !== null) {
this.enableVisualAutoscroll.set(savedVisualAutoscroll === 'true');
} else {
this.enableAcousticAutoscroll.set(false);
this.enableVisualAutoscroll.set(true);
}
const savedNotation = localStorage.getItem('chord-notation-preference');
@@ -231,29 +231,6 @@ export class SettingsService {
effect(() => {
const mode = this.fullscreenMode();
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(() => {
@@ -273,7 +250,7 @@ export class SettingsService {
});
effect(() => {
localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString());
localStorage.setItem('enable-visual-autoscroll', this.enableVisualAutoscroll().toString());
});
effect(() => {
@@ -404,10 +381,10 @@ export class SettingsService {
localStorage.setItem('enable-standard-autoscroll', newValue.toString());
}
toggleAcousticAutoscroll() {
const newValue = !this.enableAcousticAutoscroll();
this.enableAcousticAutoscroll.set(newValue);
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
toggleVisualAutoscroll() {
const newValue = !this.enableVisualAutoscroll();
this.enableVisualAutoscroll.set(newValue);
localStorage.setItem('enable-visual-autoscroll', newValue.toString());
}
toggleKaraokePageScrollMode() {
+38 -4
View File
@@ -6,6 +6,9 @@ import { Injectable, signal, effect } from '@angular/core';
export class ThemeService {
public highContrast = signal<boolean>(true);
/** Whether the system prefers dark mode */
private systemPrefersDark = signal<boolean>(false);
constructor() {
// Load from localStorage
const saved = localStorage.getItem('high-contrast');
@@ -15,18 +18,49 @@ export class ThemeService {
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(() => {
const isHigh = this.highContrast();
if (typeof document !== 'undefined' && document.body) {
if (typeof document !== 'undefined') {
const root = document.documentElement;
if (isHigh) {
document.body.classList.add('high-contrast');
root.classList.add('high-contrast');
if (document.body) document.body.classList.add('high-contrast');
} 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());
});
// 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() {
+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.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 {
border: none !important;
@@ -66,14 +66,124 @@ ion-app {
}
/* High Contrast Mode Overrides */
html.high-contrast, body.high-contrast {
color-scheme: light !important;
}
body.high-contrast {
--ion-background-color: #ffffff;
--ion-background-color-rgb: 255, 255, 255;
--ion-text-color: #000000;
--ion-text-color-rgb: 0, 0, 0;
/* Primary color - complete set for shadow DOM components */
--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 {
background: #ffffff !important;
@@ -263,6 +373,19 @@ body.high-contrast {
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 {
+38 -1
View File
@@ -34,7 +34,44 @@
</head>
<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>
</body>