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