chore: save current changes

This commit is contained in:
David Frassi
2026-06-06 08:19:08 +02:00
parent e7e43468b3
commit 4d1883a45d
23 changed files with 1028 additions and 327 deletions
+3
View File
@@ -74,6 +74,9 @@
.seg-text {
white-space: pre-wrap;
&::after {
content: '\200b';
}
}
.footer-info {
+5 -2
View File
@@ -6,7 +6,7 @@
<ion-title class="outfit-font wrapped-title">
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
<span class="canto-number" *ngIf="canto()?.id_canti">
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }}
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
@@ -26,6 +26,9 @@
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon>
</div>
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleChords()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
@@ -153,7 +156,7 @@
</div>
<!-- Camera Head Gestures Toggle in Portrait -->
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'">
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" *ngIf="settingsService.enableVisualAutoscroll()">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button>
+17
View File
@@ -197,6 +197,9 @@
.seg-text {
white-space: pre;
&::after {
content: '\200b';
}
}
}
@@ -634,6 +637,20 @@ ion-content.full-screen-content {
}
:host-context(body.high-contrast) {
.slim-toolbar {
--background: #ffffff !important;
background: #ffffff !important;
border-top: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.landscape-side-controls {
--background: #ffffff !important;
background: #ffffff !important;
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.slim-controls .group {
background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
+87 -22
View File
@@ -412,18 +412,29 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const container = document.querySelector('.lyrics-container') as HTMLElement;
if (!container) return 3; // Ritorno generico se il contenitore non è pronto
const containerHeight = container.clientHeight;
// 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) {
+1 -1
View File
@@ -24,7 +24,7 @@
<ion-icon name="reorder-two-outline"></ion-icon>
</div>
<div class="song-info">
<span class="canto-number">{{ song.id_canti }}</span>
<span class="canto-number">{{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }}</span>
<span class="song-title">
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
</span>
+6
View File
@@ -227,4 +227,10 @@ export class PlaylistPage {
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
return info && info.num_canto ? info.num_canto.toString() : null;
}
getMySongNumber(song: any): number {
if (!song || !song.id) return 0;
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
return index !== -1 ? index + 1 : 0;
}
}
@@ -7,7 +7,20 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding bg-gradient" [class.high-contrast-mode]="isHighContrast">
<ion-content class="ion-padding bg-gradient"
[class.high-contrast-mode]="isHighContrast"
[class.drag-over]="isDraggingOver"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)">
<div class="drag-overlay" *ngIf="isDraggingOver">
<div class="drag-message">
<ion-icon name="image-outline"></ion-icon>
<p>Rilascia l'immagine qui per estrarre il testo</p>
</div>
</div>
<div class="propose-container">
<!-- OCR Progress -->
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
@@ -101,7 +114,8 @@
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea">
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</div>
@@ -419,3 +419,54 @@ body.high-contrast :host ::ng-deep {
}
}
}
/* DRAG AND DROP OVERLAY */
.drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
.drag-message {
text-align: center;
color: var(--ion-color-secondary);
background: rgba(255, 255, 255, 0.1);
border: 3px dashed var(--ion-color-secondary);
border-radius: 20px;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
ion-icon {
font-size: 64px;
}
p {
font-size: 20px;
font-weight: 700;
margin: 0;
}
}
}
body.high-contrast :host ::ng-deep {
.drag-overlay {
background: rgba(255, 255, 255, 0.9);
.drag-message {
color: #000000;
background: #ffffff;
border: 3px dashed #000000;
}
}
}
+261 -16
View File
@@ -6,13 +6,14 @@ import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({
selector: 'app-propose-canto',
templateUrl: './propose-canto.page.html',
styleUrls: ['./propose-canto.page.scss'],
standalone: true,
imports: [CommonModule, FormsModule, IonicModule]
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
})
export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
@@ -22,12 +23,14 @@ export class ProposeCantoPage implements OnInit {
private myCantiService = inject(MyCantiService);
private navCtrl = inject(NavController);
public themeService = inject(ThemeService);
private route = inject(ActivatedRoute);
title: string = '';
author: string = '';
youtubeLink: string = '';
selectedLiturgico: number[] = [];
selectedTematico: number[] = [];
editId: string | null = null;
private _content: string = '';
get content(): string { return this._content; }
@@ -41,6 +44,7 @@ export class ProposeCantoPage implements OnInit {
undoStack: string[] = [];
isProcessingOCR: boolean = false;
ocrProgress: number = 0;
isDraggingOver: boolean = false;
get isHighContrast(): boolean { return this.themeService.highContrast(); }
groupedChords = [
@@ -104,6 +108,31 @@ export class ProposeCantoPage implements OnInit {
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
ngOnInit() {
this.route.queryParams.subscribe(params => {
const editId = params['editId'];
if (editId) {
this.editId = editId;
// Find the song in standard canti or personal canti list
const song = [
...this.cantiService.canti(),
...this.myCantiService.myCanti()
].find(c => c.id === editId);
if (song) {
this.title = song.titolo;
this.author = song.autore || '';
this.youtubeLink = song.link_youtube || '';
this.content = song.accordi || song.testo || '';
// Pre-populate liturgico and tematico lists
const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
const temIds = this.cantiService.indiceTematico().map(m => m.id);
this.selectedLiturgico = song.id_momenti?.filter(id => litIds.includes(id)) || [];
this.selectedTematico = song.id_momenti?.filter(id => temIds.includes(id)) || [];
}
}
});
}
async insertText(tag: string) {
@@ -143,13 +172,70 @@ export class ProposeCantoPage implements OnInit {
this.cameraInput.nativeElement.click();
}
onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Mostra l'overlay solo se si sta trascinando un file
if (event.dataTransfer?.types.includes('Files')) {
this.isDraggingOver = true;
}
}
onDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
}
async onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0];
if (file.type.indexOf('image') !== -1) {
await this.processImageFile(file);
} else {
const toast = await this.toastController.create({
message: 'Per favore, trascina un file immagine valido.',
duration: 3000,
color: 'warning'
});
toast.present();
}
}
}
async onFileSelected(event: any, isCamera: boolean) {
const file = event.target.files[0];
if (!file) {
console.log('[OCR-Capture] Nessun file selezionato.');
return;
}
await this.processImageFile(file);
event.target.value = '';
}
async onPaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
event.preventDefault(); // Prevent pasting the image representation as text
const blob = items[i].getAsFile();
if (blob) {
const file = new File([blob], 'pasted-image.png', { type: blob.type });
await this.processImageFile(file);
}
break;
}
}
}
async processImageFile(file: File) {
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
this.isProcessingOCR = true;
@@ -187,7 +273,6 @@ export class ProposeCantoPage implements OnInit {
} finally {
this.isProcessingOCR = false;
this.ocrProgress = 0;
event.target.value = '';
}
}
@@ -293,7 +378,7 @@ export class ProposeCantoPage implements OnInit {
// Calculate average word height to set vertical tolerance
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 0.6;
const verticalTolerance = avgHeight * 0.85;
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
// 2. Group words into horizontal lines
@@ -324,16 +409,43 @@ export class ProposeCantoPage implements OnInit {
});
// 3. Classify lines as Chords vs. Text
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?$/i;
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
const isChordWord = (text: string): boolean => {
const clean = text.replace(/[\[\]\(\)\.\,\-\+]/g, '').trim().toUpperCase();
if (!text || text.length === 0) return false;
const lower = text.toLowerCase();
// Skip common valid words written purely in lowercase
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
return false;
}
let clean = text.toUpperCase().replace(/\s+/g, '');
clean = clean.replace(/\((.*?)\)/g, '/$1');
clean = clean.replace(/[\.\,]$/g, '');
return chordRegex.test(clean);
};
const classifiedLines = lines.map(line => {
const chordCount = line.filter(w => isChordWord(w.text)).length;
let chordCount = 0;
let hasLongNonChord = false;
line.forEach(w => {
if (isChordWord(w.text)) {
chordCount++;
} else {
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
if (clean.length > 5) {
hasLongNonChord = true;
}
}
});
const ratio = line.length > 0 ? chordCount / line.length : 0;
const isChords = ratio >= 0.4 && line.length <= 10;
let isChords = false;
if (ratio >= 0.4 && line.length <= 10) {
if (!hasLongNonChord || ratio >= 0.75) {
isChords = true;
}
}
return {
words: line,
@@ -371,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,
+8 -8
View File
@@ -127,12 +127,12 @@
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="mic-outline" slot="start" color="secondary"></ion-icon>
<ion-icon name="eye-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Autoscroll acustico</h2>
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p>
<h2 class="settings-item-title">Autoscroll visuale</h2>
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle>
<ion-toggle slot="end" [checked]="settingsService.enableVisualAutoscroll()" (ionChange)="settingsService.toggleVisualAutoscroll()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
@@ -275,12 +275,12 @@
<div class="legend-list">
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="mic" color="danger"></ion-icon>
<ion-icon name="videocam" color="success"></ion-icon>
</div>
<div class="legend-text">
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4>
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
<p class="outfit-font">
Attiva lo scorrimento vocale intelligente. L'app ascolta il canto o lo strumento e fa scorrere testo e accordi a tempo di musica, senza bisogno di toccare lo schermo. Uno slider verticale permette di regolare la sensibilità.
Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo.
</p>
</div>
</div>
@@ -321,7 +321,7 @@
<div class="legend-text">
<h4 class="outfit-font">Riavvia Canto</h4>
<p class="outfit-font">
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento vocale dello scroll acustico.
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento dello scroll visuale.
</p>
</div>
</div>
+104 -90
View File
@@ -14,6 +14,7 @@ import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service';
import { ComunitaService } from '../../services/comunita.service';
import { environment } from '../../../environments/environment';
import { showFullscreenUpdateOverlay } from '../../app.component';
@Component({
selector: 'app-settings',
@@ -43,26 +44,6 @@ export class SettingsPage {
constructor() {}
private async forceBypassCacheAndCheck(): Promise<boolean> {
try {
// 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.update();
console.log('[PWA-Update] Native Service Worker updated from settings');
}
}
// 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali
await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' });
await fetch('/ngsw.json', { cache: 'reload' });
console.log('[PWA-Update] Caches successfully busted for ngsw.json');
} catch (e) {
console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e);
}
return await this.swUpdate.checkForUpdate();
}
async installApp() {
await this.settingsService.installPwa();
}
@@ -79,6 +60,10 @@ export class SettingsPage {
this.cantiLettureService.setSelectedMass(event.detail.value);
}
/**
* Performs a full data refresh + checks for app updates.
* Uses both the Angular SW and the version.json fallback.
*/
async fullRefresh() {
// 1. Refresh JSON data
this.cantiService.refresh();
@@ -100,40 +85,10 @@ export class SettingsPage {
}
}
// 4. Check for Service Worker updates
if (this.swUpdate.isEnabled) {
try {
const updateFound = await this.forceBypassCacheAndCheck();
if (updateFound) {
const toast = await this.toastCtrl.create({
message: 'Nuova versione disponibile! Aggiornamento in corso...',
duration: 2000,
color: 'secondary'
});
await toast.present();
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(async () => {
console.log('[PWA-Update] FullRefresh: version ready, activating...');
await this.swUpdate.activateUpdate();
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
});
setTimeout(async () => {
try {
await this.swUpdate.activateUpdate();
} catch(e) {}
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 6000);
return;
}
} catch (err) {
console.error('Failed to check for updates', err);
}
// 4. Check for app updates (SW + version.json fallback)
const updateAvailable = await this.performUpdateCheck();
if (updateAvailable) {
return; // updateAvailable already triggered the update/reload flow
}
const toast = await this.toastCtrl.create({
@@ -162,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;
}
}
}