feat: autoscroll standard, visualizzazione tag/date e OCR spaziale avanzato
- Aggiunto autoscroll standard con controllo velocità (V1-V10) e relativo toggle in settings. - Implementata la visualizzazione opzionale di tag e data aggiornamento nella lista canti. - Rinnovata la pagina 'Proponi Canto' con tastiera accordi (fondamentale + variazioni) e OCR spaziale potenziato per l'allineamento automatico degli accordi con il testo. - Ottimizzata la persistenza della modalità schermo intero.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "CantiCristiani",
|
||||
"short_name": "CantiCristiani",
|
||||
"display": "fullscreen",
|
||||
"display": "standalone",
|
||||
"scope": "/ionic/",
|
||||
"start_url": "/ionic/",
|
||||
"icons": [
|
||||
|
||||
@@ -30,11 +30,10 @@ export class AppComponent {
|
||||
this.swUpdate.versionUpdates
|
||||
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
||||
.subscribe(() => {
|
||||
if (confirm('Una nuova versione dell\'app è disponibile. Vuoi aggiornare ora?')) {
|
||||
this.swUpdate.activateUpdate().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
console.log('[PWA-Update] New version ready! Activating and reloading...');
|
||||
this.swUpdate.activateUpdate().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,10 @@
|
||||
</h2>
|
||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
||||
<span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;">
|
||||
<ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon>
|
||||
agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
|
||||
</span>
|
||||
<span *ngIf="showTopTen() && getEsecuzioniCount(canto.id) !== null" class="esecuzioni-badge" style="color: var(--ion-color-secondary); font-size: 0.75rem; background: rgba(var(--ion-color-secondary-rgb), 0.12); padding: 2px 6px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.2);">
|
||||
<ion-icon name="stats-chart-outline" style="font-size: 0.75rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
{{ getEsecuzioniCount(canto.id) }} esecuzioni
|
||||
@@ -280,10 +284,17 @@
|
||||
Attinenza {{ getMassSuggestionWeight(canto.id_canti) }}%
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Visualizza tag sotto autore -->
|
||||
<div *ngIf="settingsService.showTagsInList() && cantiService.getSongTags(canto).length > 0" class="song-tags-list">
|
||||
<span *ngFor="let tag of cantiService.getSongTags(canto)" class="song-tag-badge">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</ion-label>
|
||||
|
||||
<!-- Video Thumbnail Section -->
|
||||
<div *ngIf="canto.link_youtube && canto.link_youtube.length > 5" class="thumb-section" (click)="$event.stopPropagation()">
|
||||
<div *ngIf="cantiService.getYoutubeId(canto.link_youtube)" class="thumb-section" (click)="$event.stopPropagation()">
|
||||
<div class="thumb-container compact">
|
||||
<ng-container *ngIf="youtubePlayerService.isPlayerSupported(); else noPlayerThumb">
|
||||
<div class="thumb-wrapper" (click)="playVideo($event, canto.id)">
|
||||
|
||||
@@ -888,3 +888,36 @@ ion-title {
|
||||
color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.song-tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.song-tag-badge {
|
||||
font-size: 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* High Contrast mode overrides */
|
||||
:host-context(body.high-contrast) .song-tag-badge {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
color: rgba(0, 0, 0, 0.7) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) .update-date-badge {
|
||||
color: rgba(0, 0, 0, 0.5) !important;
|
||||
ion-icon {
|
||||
color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,20 @@
|
||||
<!-- Landscape Side Controls (Scrollable) -->
|
||||
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
|
||||
<div class="side-scroll-container">
|
||||
<!-- Autoscroll group in Landscape Side -->
|
||||
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()" style="gap: 4px; padding: 4px 0; background: rgba(255,255,255,0.05); border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); width: 44px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10" style="height: 32px; margin: 0;">
|
||||
<ion-icon name="add" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
</ion-button>
|
||||
<div (click)="toggleAutoscroll()" style="cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;">
|
||||
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'" style="font-size: 1.4rem;"></ion-icon>
|
||||
<span style="font-size: 0.65rem; font-weight: 700; color: var(--ion-color-secondary);">V{{ autoscrollSpeed() }}</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1" style="height: 32px; margin: 0;">
|
||||
<ion-icon name="remove" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="side-group equidistant-group">
|
||||
<!-- Navigation -->
|
||||
<ion-button fill="clear" (click)="restart()">
|
||||
@@ -61,7 +75,7 @@
|
||||
</ion-button>
|
||||
|
||||
<!-- Karaoke Toggle (Moved down) -->
|
||||
<ion-button fill="clear" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'">
|
||||
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'">
|
||||
<ion-icon [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
@@ -141,14 +155,28 @@
|
||||
</ion-toolbar>
|
||||
|
||||
<!-- Voice Activity Visualizer (Slim overlay) -->
|
||||
<div class="transcript-area slim" *ngIf="audioEngine.isListening()">
|
||||
<div class="transcript-area slim" *ngIf="settingsService.enableAcousticAutoscroll() && audioEngine.isListening()">
|
||||
<div class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)"></div>
|
||||
</div>
|
||||
|
||||
<ion-toolbar class="bg-gradient slim-toolbar">
|
||||
<div class="slim-controls">
|
||||
<!-- Autoscroll Standard in Portrait Footer -->
|
||||
<div class="group" *ngIf="settingsService.enableStandardAutoscroll()">
|
||||
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
|
||||
<ion-icon slot="icon-only" name="remove"></ion-icon>
|
||||
</ion-button>
|
||||
<div class="autoscroll-indicator" (click)="toggleAutoscroll()" style="display: flex; align-items: center; gap: 4px; padding: 0 4px; cursor: pointer;">
|
||||
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 32px; text-align: center;">V{{ autoscrollSpeed() }}</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
|
||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Karaoke Toggle in Portrait -->
|
||||
<ion-button fill="clear" size="small" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'" class="mic-btn-portrait">
|
||||
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" size="small" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'" class="mic-btn-portrait">
|
||||
<ion-icon slot="icon-only" [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
@@ -201,7 +229,7 @@
|
||||
</ion-footer>
|
||||
|
||||
<!-- Vertical Sensitivity Slider Overlay -->
|
||||
<div class="mic-sensitivity-overlay" *ngIf="showSensitivitySlider() && audioEngine.isListening()">
|
||||
<div class="mic-sensitivity-overlay" *ngIf="settingsService.enableAcousticAutoscroll() && showSensitivitySlider() && audioEngine.isListening()">
|
||||
<div class="slider-card glass">
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
|
||||
@@ -449,3 +449,86 @@ ion-content.full-screen-content {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-autoscroll-bar {
|
||||
position: fixed;
|
||||
bottom: 120px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(18, 18, 18, 0.85) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 6px 16px;
|
||||
border-radius: 30px;
|
||||
z-index: 999;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
pointer-events: auto;
|
||||
|
||||
&.fullscreen-active {
|
||||
bottom: 24px !important;
|
||||
}
|
||||
|
||||
&.high-contrast {
|
||||
background: #ffffff !important;
|
||||
border-color: #555555 !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.autoscroll-control-center {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
|
||||
.speed-badge {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
margin: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.autoscroll-control-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.speed-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.slim-controls .group {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.landscape-side-controls .side-scroll-container .side-group {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
public showChords = signal<boolean>(false);
|
||||
public showSensitivitySlider = signal<boolean>(false);
|
||||
|
||||
/** Autoscroll standard */
|
||||
public isAutoscrolling = signal<boolean>(false);
|
||||
public autoscrollSpeed = signal<number>(2);
|
||||
private autoscrollTimer: any = null;
|
||||
|
||||
/** Font size scale factor (1.0 = default) */
|
||||
public fontSize = signal<number>(1.0);
|
||||
|
||||
@@ -524,6 +529,47 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
return info && info.num_canto ? info.num_canto.toString() : null;
|
||||
}
|
||||
|
||||
toggleAutoscroll() {
|
||||
if (this.isAutoscrolling()) {
|
||||
this.stopAutoscroll();
|
||||
} else {
|
||||
this.startAutoscroll();
|
||||
}
|
||||
}
|
||||
|
||||
startAutoscroll() {
|
||||
this.isAutoscrolling.set(true);
|
||||
if (this.autoscrollTimer) clearInterval(this.autoscrollTimer);
|
||||
|
||||
const scrollEl = this.el.nativeElement.querySelector('.lyrics-container');
|
||||
if (!scrollEl) return;
|
||||
|
||||
this.autoscrollTimer = setInterval(() => {
|
||||
if (!this.isAutoscrolling()) {
|
||||
clearInterval(this.autoscrollTimer);
|
||||
return;
|
||||
}
|
||||
const step = this.autoscrollSpeed() * 0.25;
|
||||
scrollEl.scrollTop += step;
|
||||
}, 40);
|
||||
}
|
||||
|
||||
stopAutoscroll() {
|
||||
this.isAutoscrolling.set(false);
|
||||
if (this.autoscrollTimer) {
|
||||
clearInterval(this.autoscrollTimer);
|
||||
this.autoscrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
increaseAutoscrollSpeed() {
|
||||
this.autoscrollSpeed.update(s => Math.min(10, s + 1));
|
||||
}
|
||||
|
||||
decreaseAutoscrollSpeed() {
|
||||
this.autoscrollSpeed.update(s => Math.max(1, s - 1));
|
||||
}
|
||||
|
||||
private logPreviousSongTime() {
|
||||
const c = this.canto();
|
||||
if (c && this.songStartTime > 0) {
|
||||
@@ -534,6 +580,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stopAutoscroll();
|
||||
this.logPreviousSongTime();
|
||||
this.audioEngine.stopListening();
|
||||
this.channel.close();
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title class="outfit-font">Mio Canto</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="isHighContrast = !isHighContrast" [color]="isHighContrast ? 'warning' : 'medium'">
|
||||
<ion-icon slot="icon-only" name="contrast-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
@@ -58,9 +53,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chords Selector Toolbar -->
|
||||
<div class="toolbar-section">
|
||||
<div class="horizontal-toolbar chords-toolbar">
|
||||
<ion-button size="small" *ngFor="let chord of commonChords" (click)="insertChord(chord)">
|
||||
<div class="horizontal-toolbar main-chords-toolbar">
|
||||
<ion-button
|
||||
*ngFor="let group of groupedChords"
|
||||
size="small"
|
||||
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
|
||||
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
|
||||
(click)="selectRoot(group.root)">
|
||||
{{ group.root }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variations Toolbar (only visible if a root chord is selected) -->
|
||||
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
|
||||
<div class="horizontal-toolbar variations-toolbar">
|
||||
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
|
||||
<ion-button
|
||||
size="small"
|
||||
*ngFor="let chord of getVariations()"
|
||||
(click)="insertChord(chord)">
|
||||
{{ chord }}
|
||||
</ion-button>
|
||||
</div>
|
||||
@@ -79,9 +93,6 @@
|
||||
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
||||
<ion-icon name="camera-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="uploadDoc()">
|
||||
<ion-icon name="document-text-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
<ion-item class="custom-input-item textarea-item">
|
||||
@@ -90,8 +101,7 @@
|
||||
[(ngModel)]="content"
|
||||
placeholder="Scrivi o scansiona..."
|
||||
rows="18"
|
||||
class="content-textarea"
|
||||
(click)="openQuickMenu($event)">
|
||||
class="content-textarea">
|
||||
</ion-textarea>
|
||||
</ion-item>
|
||||
</div>
|
||||
@@ -111,34 +121,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUICK INSERT POPOVER -->
|
||||
<ion-popover [isOpen]="isChordPopoverOpen" (didDismiss)="isChordPopoverOpen = false" class="quick-popover">
|
||||
<ng-template>
|
||||
<ion-content class="ion-padding">
|
||||
<div class="popover-container">
|
||||
<div class="popover-section">
|
||||
<h6>Struttura e Tag</h6>
|
||||
<div class="popover-grid tags-grid">
|
||||
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start); isChordPopoverOpen = false">
|
||||
{{ tag.label }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="popover-section">
|
||||
<h6>Accordi Comuni</h6>
|
||||
<div class="popover-grid chords-grid">
|
||||
<ion-button *ngFor="let chord of commonChords" size="small" (click)="insertChord(chord); isChordPopoverOpen = false">
|
||||
{{ chord }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-popover>
|
||||
|
||||
<!-- Hidden inputs -->
|
||||
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
|
||||
<input type="file" #docInput (change)="onFileSelected($event, false)" accept=".pdf,.docx,.txt,.odt" style="display: none;">
|
||||
</ion-content>
|
||||
|
||||
@@ -1,3 +1,113 @@
|
||||
:host {
|
||||
--ion-background-color: #1a1a2e !important;
|
||||
--ion-text-color: #ffffff !important;
|
||||
--ion-item-background: transparent !important;
|
||||
|
||||
ion-input, ion-textarea, ion-select {
|
||||
--color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
--placeholder-color: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.high-contrast :host {
|
||||
--ion-background-color: #ffffff !important;
|
||||
--ion-text-color: #000000 !important;
|
||||
--ion-item-background: #ffffff !important;
|
||||
|
||||
ion-input, ion-textarea, ion-select {
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
--placeholder-color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Labels must be black, not teal */
|
||||
ion-label {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
/* Variations container layout in high contrast */
|
||||
.variations-container {
|
||||
background: #f5f5f5 !important;
|
||||
border: 1px solid #555555 !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.high-contrast :host ::ng-deep {
|
||||
/* Default styles for high contrast buttons (no border by default, e.g. for fill="clear") */
|
||||
ion-button {
|
||||
--background: transparent !important;
|
||||
--color: #000000 !important;
|
||||
--color-activated: #000000 !important;
|
||||
--color-focused: #000000 !important;
|
||||
--color-hover: #000000 !important;
|
||||
--border-color: transparent !important;
|
||||
--border-width: 0px !important;
|
||||
border: none !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* Outlined buttons (tags, chords, action buttons) in high contrast get single gray border */
|
||||
ion-button[fill="outline"],
|
||||
.main-chords-toolbar ion-button,
|
||||
.variations-toolbar ion-button {
|
||||
--background: #ffffff !important;
|
||||
--border-color: #555555 !important;
|
||||
--border-width: 1px !important;
|
||||
--border-style: solid !important;
|
||||
}
|
||||
|
||||
/* Target specific Light-colored outlined buttons (inactive main chords) to overpower Ionic classes */
|
||||
ion-button.ion-color-light,
|
||||
.main-chords-toolbar ion-button.ion-color-light,
|
||||
.variations-toolbar ion-button.ion-color-light {
|
||||
--background: #ffffff !important;
|
||||
--color: #000000 !important;
|
||||
--color-activated: #000000 !important;
|
||||
--color-focused: #000000 !important;
|
||||
--color-hover: #000000 !important;
|
||||
--border-color: #555555 !important;
|
||||
--border-width: 1px !important;
|
||||
--border-style: solid !important;
|
||||
color: #000000 !important;
|
||||
border: none !important;
|
||||
|
||||
/* OVERRIDE IONIC COLOR BASE FOR COLOR="LIGHT" OUTLINE BUTTONS */
|
||||
--ion-color-base: #000000 !important;
|
||||
--ion-color-base-rgb: 0, 0, 0 !important;
|
||||
}
|
||||
|
||||
/* Solid buttons (active chords, save button) in high contrast */
|
||||
ion-button.button-solid,
|
||||
ion-button[fill="solid"],
|
||||
.send-btn {
|
||||
--background: var(--ion-color-secondary) !important;
|
||||
--color: #ffffff !important;
|
||||
--color-activated: #ffffff !important;
|
||||
--color-focused: #ffffff !important;
|
||||
--color-hover: #ffffff !important;
|
||||
--border-color: #555555 !important;
|
||||
--border-width: 1px !important;
|
||||
--border-style: solid !important;
|
||||
border: none !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* Textarea inside high contrast must have white background and black text */
|
||||
.content-textarea {
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
background: #ffffff !important;
|
||||
--background: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Force background of inputs and items to be white with single dark gray border in high contrast */
|
||||
.custom-input-item, .select-item {
|
||||
--background: #ffffff !important;
|
||||
border: 1px solid #555555 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
--background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
@@ -142,81 +252,95 @@
|
||||
}
|
||||
|
||||
&.hc {
|
||||
border: 2px solid #ffffff;
|
||||
background: #000000;
|
||||
border: 2px solid #000000;
|
||||
background: #ffffff;
|
||||
|
||||
.editor-header {
|
||||
background: #ffffff;
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #000000;
|
||||
span { color: #000000; }
|
||||
small { color: #333; font-weight: 600; }
|
||||
|
||||
ion-button {
|
||||
--color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
--color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
background: #000000 !important;
|
||||
--color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
background: #ffffff !important;
|
||||
--background: #ffffff !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
caret-color: #ff00ff;
|
||||
caret-color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--padding-top: 16px;
|
||||
--padding-bottom: 16px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.category-selectors {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.select-item {
|
||||
--background: rgba(255, 255, 255, 0.04);
|
||||
--border-radius: 12px;
|
||||
|
||||
ion-select {
|
||||
width: 100%;
|
||||
--padding-start: 0;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-input-item {
|
||||
--background: rgba(255, 255, 255, 0.04);
|
||||
--border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
ion-label {
|
||||
color: var(--ion-color-secondary) !important;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
:host ::ng-deep {
|
||||
.content-textarea {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: uppercase;
|
||||
--color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--padding-top: 16px;
|
||||
--padding-bottom: 16px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
ion-input {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
.category-selectors {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-item {
|
||||
--background: transparent;
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
.select-item {
|
||||
--background: rgba(255, 255, 255, 0.08) !important;
|
||||
--border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
ion-select {
|
||||
width: 100%;
|
||||
--padding-start: 0;
|
||||
--color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-input-item {
|
||||
--background: rgba(255, 255, 255, 0.08) !important;
|
||||
--border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
ion-label {
|
||||
color: #64ffda !important; /* High-contrast teal label */
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
ion-input {
|
||||
--color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-item {
|
||||
--background: transparent;
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@@ -233,51 +357,65 @@
|
||||
box-shadow: 0 8px 20px rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
}
|
||||
|
||||
/* POPOVER STYLING */
|
||||
.quick-popover {
|
||||
--width: 90%;
|
||||
--max-width: 400px;
|
||||
--background: #1e1e1e;
|
||||
--color: white;
|
||||
|
||||
.popover-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popover-section {
|
||||
h6 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ion-color-medium);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.popover-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
ion-button {
|
||||
margin: 0;
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
--background: rgba(255,255,255,0.08);
|
||||
--color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.chords-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
/* INTERACTIVE CHORDS TOOLBAR */
|
||||
.main-chords-toolbar {
|
||||
ion-button {
|
||||
--border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
min-width: 44px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.variations-container {
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.08);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.variations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.variations-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--background: rgba(255, 255, 255, 0.12);
|
||||
--color: #ffffff;
|
||||
--border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
|
||||
&:active {
|
||||
--background: var(--ion-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IonicModule, ToastController, IonTextarea, PopoverController, NavContro
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { CantiService } from '../../services/canti.service';
|
||||
import { MyCantiService } from '../../services/my-canti.service';
|
||||
import { ThemeService } from '../../services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-propose-canto',
|
||||
@@ -16,11 +17,11 @@ import { MyCantiService } from '../../services/my-canti.service';
|
||||
export class ProposeCantoPage implements OnInit {
|
||||
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
|
||||
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
|
||||
@ViewChild('docInput', { static: false }) docInput!: ElementRef;
|
||||
|
||||
public cantiService = inject(CantiService);
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private navCtrl = inject(NavController);
|
||||
public themeService = inject(ThemeService);
|
||||
|
||||
title: string = '';
|
||||
author: string = '';
|
||||
@@ -40,20 +41,55 @@ export class ProposeCantoPage implements OnInit {
|
||||
undoStack: string[] = [];
|
||||
isProcessingOCR: boolean = false;
|
||||
ocrProgress: number = 0;
|
||||
isHighContrast: boolean = false;
|
||||
get isHighContrast(): boolean { return this.themeService.highContrast(); }
|
||||
|
||||
commonChords = [
|
||||
'DO', 'RE', 'MI', 'FA', 'SOL', 'LA', 'SI',
|
||||
'DO-', 'RE-', 'MI-', 'FA-', 'SOL-', 'LA-', 'SI-',
|
||||
'DO#', 'RE#', 'FA#', 'SOL#', 'LA#', 'DO#-', 'RE#-', 'FA#-', 'SOL#-', 'LA#-',
|
||||
'DO7', 'RE7', 'MI7', 'FA7', 'SOL7', 'LA7', 'SI7', 'DO-7', 'RE-7', 'MI-7', 'LA-7', 'SI-7',
|
||||
'DOmaj7', 'REmaj7', 'MImaj7', 'FAmaj7', 'SOLmaj7', 'LAmaj7', 'SImaj7',
|
||||
'DO4', 'RE4', 'MI4', 'FA4', 'SOL4', 'LA4', 'SI4',
|
||||
'DOdim', 'REdim', 'MIdim', 'FAdim', 'SOLdim', 'LAdim', 'SIdim',
|
||||
'DOaug', 'REaug', 'MIaug', 'FAaug', 'SOLaug', 'LAaug', 'SIaug',
|
||||
'DOm7', 'REm7', 'FAm7', 'SOLm7', 'LAm7'
|
||||
groupedChords = [
|
||||
{
|
||||
root: 'DO',
|
||||
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7']
|
||||
},
|
||||
{
|
||||
root: 'RE',
|
||||
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7']
|
||||
},
|
||||
{
|
||||
root: 'MI',
|
||||
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim']
|
||||
},
|
||||
{
|
||||
root: 'FA',
|
||||
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7']
|
||||
},
|
||||
{
|
||||
root: 'SOL',
|
||||
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7']
|
||||
},
|
||||
{
|
||||
root: 'LA',
|
||||
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LAmaj7', 'LA4', 'LAdim', 'LAm7']
|
||||
},
|
||||
{
|
||||
root: 'SI',
|
||||
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim']
|
||||
}
|
||||
];
|
||||
|
||||
selectedRootChord: string | null = null;
|
||||
|
||||
selectRoot(root: string) {
|
||||
if (this.selectedRootChord === root) {
|
||||
this.selectedRootChord = null;
|
||||
} else {
|
||||
this.selectedRootChord = root;
|
||||
}
|
||||
}
|
||||
|
||||
getVariations(): string[] {
|
||||
if (!this.selectedRootChord) return [];
|
||||
const group = this.groupedChords.find(g => g.root === this.selectedRootChord);
|
||||
return group ? group.chords : [];
|
||||
}
|
||||
|
||||
commonTags = [
|
||||
{ label: 'Ritornello', start: '{start_chorus}', end: '{end_chorus}' },
|
||||
{ label: 'Strofa', start: '{start_verse}', end: '{end_verse}' },
|
||||
@@ -65,8 +101,6 @@ export class ProposeCantoPage implements OnInit {
|
||||
{ label: 'ChordPro Rit.', start: '{soc}', end: '{eoc}' }
|
||||
];
|
||||
|
||||
isChordPopoverOpen = false;
|
||||
|
||||
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
|
||||
|
||||
ngOnInit() {
|
||||
@@ -109,46 +143,47 @@ export class ProposeCantoPage implements OnInit {
|
||||
this.cameraInput.nativeElement.click();
|
||||
}
|
||||
|
||||
uploadDoc() {
|
||||
this.docInput.nativeElement.click();
|
||||
}
|
||||
|
||||
async onFileSelected(event: any, isCamera: boolean) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
console.log('[OCR-Capture] Nessun file selezionato.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
|
||||
|
||||
this.isProcessingOCR = true;
|
||||
this.ocrProgress = 0;
|
||||
|
||||
try {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
let extractedText = '';
|
||||
console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
|
||||
const compressedBlob = await this.resizeImage(file);
|
||||
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
|
||||
console.log(`[OCR-Capture] Dimensioni dopo compressione: ${(compressedFile.size / 1024).toFixed(1)} KB`);
|
||||
|
||||
if (extension === 'pdf') {
|
||||
extractedText = await this.processPdf(file);
|
||||
} else if (extension === 'docx') {
|
||||
const mammoth = await import('mammoth');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
extractedText = result.value;
|
||||
} else if (['jpg', 'jpeg', 'png', 'webp'].includes(extension) || isCamera) {
|
||||
extractedText = await this.processImageOCR(file);
|
||||
} else {
|
||||
extractedText = await file.text();
|
||||
}
|
||||
console.log('[OCR-Capture] Avvio motore Tesseract OCR local...');
|
||||
const extractedText = await this.processImageOCR(compressedFile);
|
||||
|
||||
if (extractedText) {
|
||||
const processed = this.smartProcessOCR(extractedText);
|
||||
this.content += (this.content ? '\n\n' : '') + processed;
|
||||
console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
|
||||
this.content += (this.content ? '\n\n' : '') + extractedText;
|
||||
const toast = await this.toastController.create({
|
||||
message: 'Documento elaborato!',
|
||||
message: 'Scansione completata!',
|
||||
duration: 2000,
|
||||
color: 'success'
|
||||
});
|
||||
toast.present();
|
||||
} else {
|
||||
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File Processing Error:', error);
|
||||
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
|
||||
const errorToast = await this.toastController.create({
|
||||
message: 'Errore durante la scansione dell\'immagine.',
|
||||
duration: 3000,
|
||||
color: 'danger'
|
||||
});
|
||||
errorToast.present();
|
||||
} finally {
|
||||
this.isProcessingOCR = false;
|
||||
this.ocrProgress = 0;
|
||||
@@ -156,36 +191,268 @@ export class ProposeCantoPage implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async resizeImage(file: File): Promise<Blob> {
|
||||
console.log('[OCR-Capture] Caricamento immagine in memoria...');
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
console.log(`[OCR-Capture] Immagine caricata in memoria. Dimensioni originali: ${img.width}x${img.height}`);
|
||||
const canvas = document.createElement('canvas');
|
||||
const maxDim = 1200;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > maxDim || height > maxDim) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxDim) / width);
|
||||
width = maxDim;
|
||||
} else {
|
||||
width = Math.round((width * maxDim) / height);
|
||||
height = maxDim;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[OCR-Capture] Ridimensionamento a: ${width}x${height}`);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
console.log('[OCR-Capture] Compressione in JPEG completata con successo.');
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed'));
|
||||
}
|
||||
}, 'image/jpeg', 0.85);
|
||||
} else {
|
||||
reject(new Error('Canvas getContext 2d failed'));
|
||||
}
|
||||
};
|
||||
img.onerror = (err) => {
|
||||
console.error('[OCR-Capture] Impossibile caricare l\'immagine in memoria:', err);
|
||||
reject(err);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async processImageOCR(file: File): Promise<string> {
|
||||
console.log('[OCR-Capture] Inizializzazione Worker Tesseract.js...');
|
||||
const worker = await createWorker('ita', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text') this.ocrProgress = m.progress;
|
||||
if (m.status === 'recognizing text') {
|
||||
this.ocrProgress = m.progress;
|
||||
console.log(`[OCR-Capture] Progresso OCR: ${(m.progress * 100).toFixed(0)}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const { data: { text } } = await worker.recognize(file);
|
||||
console.log('[OCR-Capture] Avvio riconoscimento caratteri (OCR) con blocks abilitato...');
|
||||
const { data } = await worker.recognize(file, {}, { blocks: true });
|
||||
console.log('[OCR-Capture] Riconoscimento caratteri terminato. Spegnimento worker...');
|
||||
await worker.terminate();
|
||||
return text;
|
||||
}
|
||||
console.log('[OCR-Capture] Spegnimento worker completato. Estrazione parole...');
|
||||
|
||||
async processPdf(file: File): Promise<string> {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
// Set worker src from CDN for PWA compatibility
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
let fullText = '';
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items.map((item: any) => item.str).join(' ');
|
||||
fullText += pageText + '\n';
|
||||
// Flatten blocks hierarchy to get a flat words list
|
||||
const words: any[] = [];
|
||||
if (data && (data as any).blocks) {
|
||||
const blocks = (data as any).blocks;
|
||||
blocks.forEach((block: any) => {
|
||||
if (block.paragraphs) {
|
||||
block.paragraphs.forEach((paragraph: any) => {
|
||||
if (paragraph.lines) {
|
||||
paragraph.lines.forEach((line: any) => {
|
||||
if (line.words) {
|
||||
line.words.forEach((word: any) => {
|
||||
words.push(word);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return fullText;
|
||||
|
||||
console.log(`[OCR-Capture] Parole estratte dal blocco gerarchico: ${words.length}`);
|
||||
return this.parseSongSpatially(words);
|
||||
}
|
||||
|
||||
parseSongSpatially(words: any[]): string {
|
||||
if (!words || words.length === 0) {
|
||||
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log(`[OCR-Capture] Parole totali ricevute dall'OCR: ${words.length}`);
|
||||
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
|
||||
console.log(`[OCR-Capture] Parole valide dopo filtraggio: ${validWords.length}`);
|
||||
if (validWords.length === 0) return '';
|
||||
|
||||
// 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;
|
||||
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
|
||||
|
||||
// 2. Group words into horizontal lines
|
||||
const lines: any[][] = [];
|
||||
validWords.forEach(word => {
|
||||
let added = false;
|
||||
for (const line of lines) {
|
||||
const avgLineY0 = line.reduce((sum, w) => sum + w.bbox.y0, 0) / line.length;
|
||||
if (Math.abs(word.bbox.y0 - avgLineY0) < verticalTolerance) {
|
||||
line.push(word);
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!added) {
|
||||
lines.push([word]);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort words horizontally in each line
|
||||
lines.forEach(line => line.sort((a, b) => a.bbox.x0 - b.bbox.x0));
|
||||
|
||||
// Sort all lines vertically by average y0
|
||||
lines.sort((a, b) => {
|
||||
const avgA = a.reduce((sum, w) => sum + w.bbox.y0, 0) / a.length;
|
||||
const avgB = b.reduce((sum, w) => sum + w.bbox.y0, 0) / b.length;
|
||||
return avgA - avgB;
|
||||
});
|
||||
|
||||
// 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 isChordWord = (text: string): boolean => {
|
||||
const clean = text.replace(/[\[\]\(\)\.\,\-\+]/g, '').trim().toUpperCase();
|
||||
return chordRegex.test(clean);
|
||||
};
|
||||
|
||||
const classifiedLines = lines.map(line => {
|
||||
const chordCount = line.filter(w => isChordWord(w.text)).length;
|
||||
const ratio = line.length > 0 ? chordCount / line.length : 0;
|
||||
const isChords = ratio >= 0.4 && line.length <= 10;
|
||||
|
||||
return {
|
||||
words: line,
|
||||
isChords: isChords,
|
||||
yCenter: line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1)/2, 0) / line.length
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Merge chords and text lines
|
||||
const processedLines: string[] = [];
|
||||
let inChorus = false;
|
||||
let inVerse = false;
|
||||
const chorusStartRegex = /^(R:|Rit\.|Rit|Ritornello|Coro)/i;
|
||||
|
||||
for (let i = 0; i < classifiedLines.length; i++) {
|
||||
const current = classifiedLines[i];
|
||||
|
||||
if (current.isChords) {
|
||||
const next = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
||||
if (next && !next.isChords) {
|
||||
// Merge spatially!
|
||||
const merged = this.mergeChordsAndLyrics(current.words, next.words);
|
||||
const lineText = next.words.map(w => w.text).join(' ');
|
||||
const isChorus = chorusStartRegex.test(lineText);
|
||||
|
||||
if (isChorus) {
|
||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
|
||||
} else if (!inChorus && !inVerse && lineText.length > 5) {
|
||||
processedLines.push('{start_verse}');
|
||||
inVerse = true;
|
||||
}
|
||||
|
||||
processedLines.push(merged);
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
const lineText = current.words.map(w => w.text).join(' ');
|
||||
const isChorus = chorusStartRegex.test(lineText);
|
||||
|
||||
if (isChorus) {
|
||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
|
||||
} else if (!inChorus && !inVerse && lineText.length > 5) {
|
||||
processedLines.push('{start_verse}');
|
||||
inVerse = true;
|
||||
}
|
||||
|
||||
processedLines.push(lineText);
|
||||
}
|
||||
|
||||
// If we see a large vertical gap, close open blocks
|
||||
const currentNext = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
||||
if (currentNext) {
|
||||
const gap = currentNext.yCenter - current.yCenter;
|
||||
if (gap > avgHeight * 2.5) {
|
||||
if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; }
|
||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||
processedLines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inChorus) processedLines.push('{end_chorus}');
|
||||
if (inVerse) processedLines.push('{end_verse}');
|
||||
|
||||
return processedLines.join('\n');
|
||||
}
|
||||
|
||||
mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
|
||||
let result = '';
|
||||
const chordAssignments = new Map<any, any[]>();
|
||||
|
||||
chordWords.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);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestWord = textWord;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestWord) {
|
||||
if (!chordAssignments.has(closestWord)) {
|
||||
chordAssignments.set(closestWord, []);
|
||||
}
|
||||
chordAssignments.get(closestWord)!.push(chord);
|
||||
}
|
||||
});
|
||||
|
||||
textWords.forEach((textWord, index) => {
|
||||
const assignedChords = chordAssignments.get(textWord) || [];
|
||||
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||
|
||||
assignedChords.forEach(chord => {
|
||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||
result += `[${cleanChord}]`;
|
||||
});
|
||||
|
||||
result += textWord.text;
|
||||
if (index < textWords.length - 1) {
|
||||
result += ' ';
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
smartProcessOCR(text: string): string {
|
||||
let lines = text.split('\n');
|
||||
let processedLines: string[] = [];
|
||||
@@ -230,9 +497,7 @@ export class ProposeCantoPage implements OnInit {
|
||||
return line;
|
||||
}
|
||||
|
||||
async openQuickMenu(event: any) {
|
||||
this.isChordPopoverOpen = true;
|
||||
}
|
||||
|
||||
|
||||
async saveToMyCanti() {
|
||||
if (!this.title || !this.content) return;
|
||||
|
||||
@@ -57,7 +57,39 @@
|
||||
<h2 class="settings-item-title">Editor e Canti Personali</h2>
|
||||
<p class="settings-item-subtitle">Abilita aggiunta e gestione "Miei"</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showEditor()" (ionChange)="settingsService.toggleEditor()" color="secondary" [disabled]="true"></ion-toggle>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showEditor()" (ionChange)="settingsService.toggleEditor()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="pricetags-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Visualizza tag</h2>
|
||||
<p class="settings-item-subtitle">Mostra filtri sotto autore nella lista canti</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showTagsInList()" (ionChange)="settingsService.toggleShowTagsInList()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="calendar-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Visualizza data update</h2>
|
||||
<p class="settings-item-subtitle">Mostra data aggiornamento nella lista canti</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Autoscroll standard</h2>
|
||||
<p class="settings-item-subtitle">Abilita 3 tasti di autoscroll nel canto</p>
|
||||
</ion-label>
|
||||
<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-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Autoscroll acustico</h2>
|
||||
<p class="settings-item-subtitle">Mostra microfono per scorrimento vocale</p>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Canto {
|
||||
autore?: string;
|
||||
link_youtube?: string;
|
||||
id_momenti?: number[];
|
||||
data_update?: string;
|
||||
}
|
||||
|
||||
export interface Indice {
|
||||
@@ -142,6 +143,37 @@ export class CantiService {
|
||||
return this.canti().find(c => c.id === id);
|
||||
}
|
||||
|
||||
getSongTags(canto: Canto): string[] {
|
||||
if (!canto.id_momenti || canto.id_momenti.length === 0) return [];
|
||||
const tags: string[] = [];
|
||||
const lit = this.indiceLiturgico();
|
||||
const tem = this.indiceTematico();
|
||||
|
||||
canto.id_momenti.forEach(id => {
|
||||
const matchLit = lit.find(x => x.id === id);
|
||||
if (matchLit) {
|
||||
tags.push(matchLit.tag_name);
|
||||
} else {
|
||||
const matchTem = tem.find(x => x.id === id);
|
||||
if (matchTem) {
|
||||
tags.push(matchTem.tag_name);
|
||||
}
|
||||
}
|
||||
});
|
||||
return tags;
|
||||
}
|
||||
|
||||
formatUpdateDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const parts = dateStr.split(' ')[0].split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
getYoutubeId(urlOrId: string | undefined): string | null {
|
||||
if (!urlOrId) return null;
|
||||
if (urlOrId.length === 11) return urlOrId;
|
||||
|
||||
@@ -35,6 +35,18 @@ export class SettingsService {
|
||||
/** Invio dati statistici: true = invia pacchetto dati statistici */
|
||||
public invioDatiStatistici = signal<boolean>(false);
|
||||
|
||||
/** Visualizza tag sotto autore nella lista canti: true = attivo */
|
||||
public showTagsInList = signal<boolean>(false);
|
||||
|
||||
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
||||
public showUpdateDate = signal<boolean>(false);
|
||||
|
||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
||||
public enableStandardAutoscroll = signal<boolean>(false);
|
||||
|
||||
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
|
||||
public enableAcousticAutoscroll = signal<boolean>(true);
|
||||
|
||||
private wakeLock: any = null;
|
||||
|
||||
constructor() {
|
||||
@@ -48,13 +60,10 @@ export class SettingsService {
|
||||
this.fullscreenMode.set(savedFullscreen === 'true');
|
||||
}
|
||||
|
||||
/*
|
||||
const savedEditor = localStorage.getItem('show-editor');
|
||||
if (savedEditor !== null) {
|
||||
this.showEditor.set(savedEditor === 'true');
|
||||
}
|
||||
*/
|
||||
this.showEditor.set(false); // Funzione temporaneamente disabilitata
|
||||
|
||||
const savedAutoAdvance = localStorage.getItem('auto-advance');
|
||||
if (savedAutoAdvance !== null) {
|
||||
@@ -76,6 +85,26 @@ export class SettingsService {
|
||||
this.invioDatiStatistici.set(savedStats === 'true');
|
||||
}
|
||||
|
||||
const savedShowTags = localStorage.getItem('show-tags-in-list');
|
||||
if (savedShowTags !== null) {
|
||||
this.showTagsInList.set(savedShowTags === 'true');
|
||||
}
|
||||
|
||||
const savedShowUpdateDate = localStorage.getItem('show-update-date');
|
||||
if (savedShowUpdateDate !== null) {
|
||||
this.showUpdateDate.set(savedShowUpdateDate === 'true');
|
||||
}
|
||||
|
||||
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
||||
if (savedStandardAutoscroll !== null) {
|
||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||
}
|
||||
|
||||
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
|
||||
if (savedAcousticAutoscroll !== null) {
|
||||
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
|
||||
}
|
||||
|
||||
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||
const updateFullscreenState = () => {
|
||||
const isFs = !!(
|
||||
@@ -97,13 +126,53 @@ export class SettingsService {
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('fullscreen-mode', this.fullscreenMode().toString());
|
||||
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(() => {
|
||||
localStorage.setItem('auto-advance', this.autoAdvance().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('show-tags-in-list', this.showTagsInList().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('show-update-date', this.showUpdateDate().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const active = this.keepScreenOn();
|
||||
localStorage.setItem('keep-screen-on', active.toString());
|
||||
@@ -205,4 +274,28 @@ export class SettingsService {
|
||||
this.invioDatiStatistici.set(newValue);
|
||||
localStorage.setItem('invio-dati-statistici', newValue.toString());
|
||||
}
|
||||
|
||||
toggleShowTagsInList() {
|
||||
const newValue = !this.showTagsInList();
|
||||
this.showTagsInList.set(newValue);
|
||||
localStorage.setItem('show-tags-in-list', newValue.toString());
|
||||
}
|
||||
|
||||
toggleShowUpdateDate() {
|
||||
const newValue = !this.showUpdateDate();
|
||||
this.showUpdateDate.set(newValue);
|
||||
localStorage.setItem('show-update-date', newValue.toString());
|
||||
}
|
||||
|
||||
toggleStandardAutoscroll() {
|
||||
const newValue = !this.enableStandardAutoscroll();
|
||||
this.enableStandardAutoscroll.set(newValue);
|
||||
localStorage.setItem('enable-standard-autoscroll', newValue.toString());
|
||||
}
|
||||
|
||||
toggleAcousticAutoscroll() {
|
||||
const newValue = !this.enableAcousticAutoscroll();
|
||||
this.enableAcousticAutoscroll.set(newValue);
|
||||
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import { Injectable, signal, effect } from '@angular/core';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
public highContrast = signal<boolean>(true);
|
||||
public highContrast = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Load from localStorage
|
||||
const saved = localStorage.getItem('high-contrast');
|
||||
if (saved === 'false') {
|
||||
this.highContrast.set(false);
|
||||
} else {
|
||||
if (saved === 'true') {
|
||||
this.highContrast.set(true);
|
||||
} else {
|
||||
this.highContrast.set(false);
|
||||
}
|
||||
|
||||
// Effect to apply class to body
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.05.18.1550';
|
||||
export const VERSION = '2026.05.18.1750';
|
||||
|
||||
Reference in New Issue
Block a user