chore: save current changes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+55
-118
@@ -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> {
|
export function showFullscreenUpdateOverlay() {
|
||||||
try {
|
const overlay = document.createElement('div');
|
||||||
// 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo
|
overlay.id = 'pwa-update-overlay';
|
||||||
if ('serviceWorker' in navigator) {
|
overlay.style.position = 'fixed';
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
overlay.style.top = '0';
|
||||||
for (const registration of registrations) {
|
overlay.style.left = '0';
|
||||||
await registration.update();
|
overlay.style.width = '100vw';
|
||||||
console.log('[PWA-Update] Native Service Worker updated');
|
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);
|
||||||
// 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali
|
|
||||||
await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' });
|
function updatePercent(val: number) {
|
||||||
await fetch('/ngsw.json', { cache: 'reload' });
|
const bar = document.getElementById('pwa-update-bar');
|
||||||
console.log('[PWA-Update] Caches successfully busted for ngsw.json');
|
const txt = document.getElementById('pwa-update-percent');
|
||||||
} catch (e) {
|
if (bar) bar.style.width = val + '%';
|
||||||
console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e);
|
if (txt) txt.textContent = val + '%';
|
||||||
}
|
|
||||||
return await this.swUpdate.checkForUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupUpdates() {
|
return {
|
||||||
if (this.swUpdate.isEnabled) {
|
finish: () => {
|
||||||
const launchTime = Date.now();
|
clearInterval(interval);
|
||||||
|
updatePercent(100);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -74,6 +74,9 @@
|
|||||||
|
|
||||||
.seg-text {
|
.seg-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
&::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-info {
|
.footer-info {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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;
|
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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,9 +483,30 @@ 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;
|
||||||
|
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);
|
processedLines.push(wrapped);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const lineText = current.words.map(w => w.text).join(' ');
|
const lineText = current.words.map(w => w.text).join(' ');
|
||||||
const isChorus = chorusStartRegex.test(lineText);
|
const isChorus = chorusStartRegex.test(lineText);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,36 +117,9 @@ export class SettingsPage {
|
|||||||
});
|
});
|
||||||
await toastLoading.present();
|
await toastLoading.present();
|
||||||
|
|
||||||
try {
|
const updateAvailable = await this.performUpdateCheck();
|
||||||
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();
|
|
||||||
|
|
||||||
// Sottoscrizione per attivare l'aggiornamento appena terminato il download
|
if (!updateAvailable) {
|
||||||
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({
|
const toast = await this.toastCtrl.create({
|
||||||
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
|
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
@@ -199,14 +127,100 @@ export class SettingsPage {
|
|||||||
});
|
});
|
||||||
await toast.present();
|
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) {
|
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,45 @@ 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[];
|
||||||
|
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: `my_${Date.now()}`,
|
||||||
id_canti: Date.now(), // Fake ID for internal logic
|
id_canti: Date.now(), // Fake ID for internal logic
|
||||||
titolo: canto.titolo || 'Senza Titolo',
|
titolo: canto.titolo || 'Senza Titolo',
|
||||||
@@ -44,8 +80,9 @@ export class MyCantiService {
|
|||||||
link_youtube: canto.link_youtube,
|
link_youtube: canto.link_youtube,
|
||||||
id_momenti: canto.id_momenti || []
|
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) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
export const VERSION = '2026.06.04.1906';
|
export const VERSION = '2026.06.06.0038';
|
||||||
|
|||||||
+126
-3
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user