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:
David Frassi
2026-05-20 11:26:59 +02:00
parent af12a1f2da
commit 21c9c206e1
15 changed files with 970 additions and 225 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "CantiCristiani", "name": "CantiCristiani",
"short_name": "CantiCristiani", "short_name": "CantiCristiani",
"display": "fullscreen", "display": "standalone",
"scope": "/ionic/", "scope": "/ionic/",
"start_url": "/ionic/", "start_url": "/ionic/",
"icons": [ "icons": [
+4 -5
View File
@@ -30,11 +30,10 @@ export class AppComponent {
this.swUpdate.versionUpdates this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY')) .pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
.subscribe(() => { .subscribe(() => {
if (confirm('Una nuova versione dell\'app è disponibile. Vuoi aggiornare ora?')) { console.log('[PWA-Update] New version ready! Activating and reloading...');
this.swUpdate.activateUpdate().then(() => { this.swUpdate.activateUpdate().then(() => {
window.location.reload(); window.location.reload();
}); });
}
}); });
} }
} }
+12 -1
View File
@@ -271,6 +271,10 @@
</h2> </h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <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>{{ 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);"> <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> <ion-icon name="stats-chart-outline" style="font-size: 0.75rem; color: var(--ion-color-secondary);"></ion-icon>
{{ getEsecuzioniCount(canto.id) }} esecuzioni {{ getEsecuzioniCount(canto.id) }} esecuzioni
@@ -280,10 +284,17 @@
Attinenza {{ getMassSuggestionWeight(canto.id_canti) }}% Attinenza {{ getMassSuggestionWeight(canto.id_canti) }}%
</span> </span>
</p> </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> </ion-label>
<!-- Video Thumbnail Section --> <!-- 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"> <div class="thumb-container compact">
<ng-container *ngIf="youtubePlayerService.isPlayerSupported(); else noPlayerThumb"> <ng-container *ngIf="youtubePlayerService.isPlayerSupported(); else noPlayerThumb">
<div class="thumb-wrapper" (click)="playVideo($event, canto.id)"> <div class="thumb-wrapper" (click)="playVideo($event, canto.id)">
+33
View File
@@ -888,3 +888,36 @@ ion-title {
color: rgba(0, 0, 0, 0.6) !important; 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;
}
}
+32 -4
View File
@@ -40,6 +40,20 @@
<!-- Landscape Side Controls (Scrollable) --> <!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()"> <div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container"> <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"> <div class="side-group equidistant-group">
<!-- Navigation --> <!-- Navigation -->
<ion-button fill="clear" (click)="restart()"> <ion-button fill="clear" (click)="restart()">
@@ -61,7 +75,7 @@
</ion-button> </ion-button>
<!-- Karaoke Toggle (Moved down) --> <!-- 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-icon [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
</ion-button> </ion-button>
@@ -141,14 +155,28 @@
</ion-toolbar> </ion-toolbar>
<!-- Voice Activity Visualizer (Slim overlay) --> <!-- 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 class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)"></div>
</div> </div>
<ion-toolbar class="bg-gradient slim-toolbar"> <ion-toolbar class="bg-gradient slim-toolbar">
<div class="slim-controls"> <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 --> <!-- 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-icon slot="icon-only" [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
</ion-button> </ion-button>
@@ -201,7 +229,7 @@
</ion-footer> </ion-footer>
<!-- Vertical Sensitivity Slider Overlay --> <!-- 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"> <div class="slider-card glass">
<ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn"> <ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn">
<ion-icon name="close-outline"></ion-icon> <ion-icon name="close-outline"></ion-icon>
+83
View File
@@ -449,3 +449,86 @@ ion-content.full-screen-content {
transform: translateX(0); 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;
}
}
+47
View File
@@ -27,6 +27,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public showChords = signal<boolean>(false); public showChords = signal<boolean>(false);
public showSensitivitySlider = 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) */ /** Font size scale factor (1.0 = default) */
public fontSize = signal<number>(1.0); 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; 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() { private logPreviousSongTime() {
const c = this.canto(); const c = this.canto();
if (c && this.songStartTime > 0) { if (c && this.songStartTime > 0) {
@@ -534,6 +580,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
ngOnDestroy() { ngOnDestroy() {
this.stopAutoscroll();
this.logPreviousSongTime(); this.logPreviousSongTime();
this.audioEngine.stopListening(); this.audioEngine.stopListening();
this.channel.close(); this.channel.close();
@@ -4,11 +4,6 @@
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button> <ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons> </ion-buttons>
<ion-title class="outfit-font">Mio Canto</ion-title> <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-toolbar>
</ion-header> </ion-header>
@@ -58,9 +53,28 @@
</div> </div>
</div> </div>
<!-- Main Chords Selector Toolbar -->
<div class="toolbar-section"> <div class="toolbar-section">
<div class="horizontal-toolbar chords-toolbar"> <div class="horizontal-toolbar main-chords-toolbar">
<ion-button size="small" *ngFor="let chord of commonChords" (click)="insertChord(chord)"> <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 }} {{ chord }}
</ion-button> </ion-button>
</div> </div>
@@ -79,9 +93,6 @@
<ion-button fill="clear" size="small" (click)="takePhoto()"> <ion-button fill="clear" size="small" (click)="takePhoto()">
<ion-icon name="camera-outline"></ion-icon> <ion-icon name="camera-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" size="small" (click)="uploadDoc()">
<ion-icon name="document-text-outline"></ion-icon>
</ion-button>
</div> </div>
</div> </div>
<ion-item class="custom-input-item textarea-item"> <ion-item class="custom-input-item textarea-item">
@@ -90,8 +101,7 @@
[(ngModel)]="content" [(ngModel)]="content"
placeholder="Scrivi o scansiona..." placeholder="Scrivi o scansiona..."
rows="18" rows="18"
class="content-textarea" class="content-textarea">
(click)="openQuickMenu($event)">
</ion-textarea> </ion-textarea>
</ion-item> </ion-item>
</div> </div>
@@ -111,34 +121,8 @@
</div> </div>
</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 --> <!-- Hidden inputs -->
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;"> <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> </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 { .bg-gradient {
--background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); --background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
} }
@@ -142,81 +252,95 @@
} }
&.hc { &.hc {
border: 2px solid #ffffff; border: 2px solid #000000;
background: #000000; background: #ffffff;
.editor-header { .editor-header {
background: #ffffff; background: #f0f0f0;
border-bottom: 2px solid #000000;
span { color: #000000; } span { color: #000000; }
small { color: #333; font-weight: 600; } small { color: #333; font-weight: 600; }
ion-button {
--color: #000000 !important;
}
} }
.content-textarea { .content-textarea {
--color: #ffffff !important; --color: #000000 !important;
color: #ffffff !important; color: #000000 !important;
background: #000000 !important; background: #ffffff !important;
--background: #ffffff !important;
font-size: 18px !important; font-size: 18px !important;
font-weight: 700 !important; font-weight: 700 !important;
caret-color: #ff00ff; caret-color: #000000;
} }
} }
} }
.content-textarea { :host ::ng-deep {
font-family: 'Courier New', Courier, monospace; .content-textarea {
font-size: 15px; font-family: 'Courier New', Courier, monospace;
font-weight: 500; font-size: 15px;
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;
font-weight: 600; font-weight: 600;
margin-bottom: 6px !important; --color: #ffffff !important;
font-size: 12px !important; color: #ffffff !important;
text-transform: uppercase; --padding-start: 16px;
--padding-end: 16px;
--padding-top: 16px;
--padding-bottom: 16px;
min-height: 400px;
} }
ion-input { .category-selectors {
color: white; display: grid;
font-weight: 500; grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
} }
}
.textarea-item { .select-item {
--background: transparent; --background: rgba(255, 255, 255, 0.08) !important;
--padding-start: 0; --border-radius: 12px;
--padding-end: 0; 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 { .action-buttons {
@@ -233,51 +357,65 @@
box-shadow: 0 8px 20px rgba(var(--ion-color-secondary-rgb), 0.3); box-shadow: 0 8px 20px rgba(var(--ion-color-secondary-rgb), 0.3);
} }
/* POPOVER STYLING */ /* INTERACTIVE CHORDS TOOLBAR */
.quick-popover { .main-chords-toolbar {
--width: 90%; ion-button {
--max-width: 400px; --border-radius: 8px;
--background: #1e1e1e; font-weight: 700;
--color: white; font-size: 13px;
min-width: 44px;
.popover-container { margin: 0;
display: flex; }
flex-direction: column; }
gap: 16px;
padding: 16px; .variations-container {
} background: rgba(var(--ion-color-secondary-rgb), 0.08);
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.25);
.popover-section { border-radius: 12px;
h6 { padding: 8px 12px;
margin: 0 0 8px 0; margin-top: 8px;
font-size: 10px; margin-bottom: 12px;
text-transform: uppercase; animation: fadeIn 0.2s ease-out;
color: var(--ion-color-medium); }
letter-spacing: 1px;
} @keyframes fadeIn {
} from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
.popover-grid { }
display: grid;
gap: 4px; .variations-toolbar {
display: flex;
ion-button { align-items: center;
margin: 0; gap: 8px;
--padding-start: 4px; overflow-x: auto;
--padding-end: 4px; scrollbar-width: none;
font-size: 12px;
height: 32px; &::-webkit-scrollbar {
--background: rgba(255,255,255,0.08); display: none;
--color: white; }
font-weight: 700;
} .variations-label {
} font-size: 11px;
font-weight: 700;
.tags-grid { color: var(--ion-color-secondary);
grid-template-columns: repeat(2, 1fr); text-transform: uppercase;
} letter-spacing: 0.5px;
flex-shrink: 0;
.chords-grid { }
grid-template-columns: repeat(4, 1fr);
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);
}
} }
} }
+325 -60
View File
@@ -5,6 +5,7 @@ import { IonicModule, ToastController, IonTextarea, PopoverController, NavContro
import { createWorker } from 'tesseract.js'; import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service'; import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service'; import { MyCantiService } from '../../services/my-canti.service';
import { ThemeService } from '../../services/theme.service';
@Component({ @Component({
selector: 'app-propose-canto', selector: 'app-propose-canto',
@@ -16,11 +17,11 @@ import { MyCantiService } from '../../services/my-canti.service';
export class ProposeCantoPage implements OnInit { export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea; @ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef; @ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
@ViewChild('docInput', { static: false }) docInput!: ElementRef;
public cantiService = inject(CantiService); public cantiService = inject(CantiService);
private myCantiService = inject(MyCantiService); private myCantiService = inject(MyCantiService);
private navCtrl = inject(NavController); private navCtrl = inject(NavController);
public themeService = inject(ThemeService);
title: string = ''; title: string = '';
author: string = ''; author: string = '';
@@ -40,20 +41,55 @@ export class ProposeCantoPage implements OnInit {
undoStack: string[] = []; undoStack: string[] = [];
isProcessingOCR: boolean = false; isProcessingOCR: boolean = false;
ocrProgress: number = 0; ocrProgress: number = 0;
isHighContrast: boolean = false; get isHighContrast(): boolean { return this.themeService.highContrast(); }
commonChords = [ groupedChords = [
'DO', 'RE', 'MI', 'FA', 'SOL', 'LA', 'SI', {
'DO-', 'RE-', 'MI-', 'FA-', 'SOL-', 'LA-', 'SI-', root: 'DO',
'DO#', 'RE#', 'FA#', 'SOL#', 'LA#', 'DO#-', 'RE#-', 'FA#-', 'SOL#-', 'LA#-', chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7']
'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', root: 'RE',
'DOdim', 'REdim', 'MIdim', 'FAdim', 'SOLdim', 'LAdim', 'SIdim', chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7']
'DOaug', 'REaug', 'MIaug', 'FAaug', 'SOLaug', 'LAaug', 'SIaug', },
'DOm7', 'REm7', 'FAm7', 'SOLm7', 'LAm7' {
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 = [ commonTags = [
{ label: 'Ritornello', start: '{start_chorus}', end: '{end_chorus}' }, { label: 'Ritornello', start: '{start_chorus}', end: '{end_chorus}' },
{ label: 'Strofa', start: '{start_verse}', end: '{end_verse}' }, { label: 'Strofa', start: '{start_verse}', end: '{end_verse}' },
@@ -65,8 +101,6 @@ export class ProposeCantoPage implements OnInit {
{ label: 'ChordPro Rit.', start: '{soc}', end: '{eoc}' } { label: 'ChordPro Rit.', start: '{soc}', end: '{eoc}' }
]; ];
isChordPopoverOpen = false;
constructor(private toastController: ToastController, private popoverController: PopoverController) { } constructor(private toastController: ToastController, private popoverController: PopoverController) { }
ngOnInit() { ngOnInit() {
@@ -109,46 +143,47 @@ export class ProposeCantoPage implements OnInit {
this.cameraInput.nativeElement.click(); this.cameraInput.nativeElement.click();
} }
uploadDoc() {
this.docInput.nativeElement.click();
}
async onFileSelected(event: any, isCamera: boolean) { async onFileSelected(event: any, isCamera: boolean) {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) 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.isProcessingOCR = true;
this.ocrProgress = 0; this.ocrProgress = 0;
try { try {
const extension = file.name.split('.').pop().toLowerCase(); console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
let extractedText = ''; 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') { console.log('[OCR-Capture] Avvio motore Tesseract OCR local...');
extractedText = await this.processPdf(file); const extractedText = await this.processImageOCR(compressedFile);
} 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();
}
if (extractedText) { if (extractedText) {
const processed = this.smartProcessOCR(extractedText); console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
this.content += (this.content ? '\n\n' : '') + processed; this.content += (this.content ? '\n\n' : '') + extractedText;
const toast = await this.toastController.create({ const toast = await this.toastController.create({
message: 'Documento elaborato!', message: 'Scansione completata!',
duration: 2000, duration: 2000,
color: 'success' color: 'success'
}); });
toast.present(); toast.present();
} else {
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
} }
} catch (error) { } 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 { } finally {
this.isProcessingOCR = false; this.isProcessingOCR = false;
this.ocrProgress = 0; 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> { async processImageOCR(file: File): Promise<string> {
console.log('[OCR-Capture] Inizializzazione Worker Tesseract.js...');
const worker = await createWorker('ita', 1, { const worker = await createWorker('ita', 1, {
logger: m => { 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(); await worker.terminate();
return text; console.log('[OCR-Capture] Spegnimento worker completato. Estrazione parole...');
}
async processPdf(file: File): Promise<string> { // Flatten blocks hierarchy to get a flat words list
const pdfjsLib = await import('pdfjs-dist'); const words: any[] = [];
// Set worker src from CDN for PWA compatibility if (data && (data as any).blocks) {
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`; const blocks = (data as any).blocks;
blocks.forEach((block: any) => {
const arrayBuffer = await file.arrayBuffer(); if (block.paragraphs) {
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); block.paragraphs.forEach((paragraph: any) => {
const pdf = await loadingTask.promise; if (paragraph.lines) {
paragraph.lines.forEach((line: any) => {
let fullText = ''; if (line.words) {
for (let i = 1; i <= pdf.numPages; i++) { line.words.forEach((word: any) => {
const page = await pdf.getPage(i); words.push(word);
const textContent = await page.getTextContent(); });
const pageText = textContent.items.map((item: any) => item.str).join(' '); }
fullText += pageText + '\n'; });
}
});
}
});
} }
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 { smartProcessOCR(text: string): string {
let lines = text.split('\n'); let lines = text.split('\n');
let processedLines: string[] = []; let processedLines: string[] = [];
@@ -230,9 +497,7 @@ export class ProposeCantoPage implements OnInit {
return line; return line;
} }
async openQuickMenu(event: any) {
this.isChordPopoverOpen = true;
}
async saveToMyCanti() { async saveToMyCanti() {
if (!this.title || !this.content) return; if (!this.title || !this.content) return;
+33 -1
View File
@@ -57,7 +57,39 @@
<h2 class="settings-item-title">Editor e Canti Personali</h2> <h2 class="settings-item-title">Editor e Canti Personali</h2>
<p class="settings-item-subtitle">Abilita aggiunta e gestione "Miei"</p> <p class="settings-item-subtitle">Abilita aggiunta e gestione "Miei"</p>
</ion-label> </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> </ion-item>
</div> </div>
+32
View File
@@ -11,6 +11,7 @@ export interface Canto {
autore?: string; autore?: string;
link_youtube?: string; link_youtube?: string;
id_momenti?: number[]; id_momenti?: number[];
data_update?: string;
} }
export interface Indice { export interface Indice {
@@ -142,6 +143,37 @@ export class CantiService {
return this.canti().find(c => c.id === id); 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 { getYoutubeId(urlOrId: string | undefined): string | null {
if (!urlOrId) return null; if (!urlOrId) return null;
if (urlOrId.length === 11) return urlOrId; if (urlOrId.length === 11) return urlOrId;
+97 -4
View File
@@ -35,6 +35,18 @@ export class SettingsService {
/** Invio dati statistici: true = invia pacchetto dati statistici */ /** Invio dati statistici: true = invia pacchetto dati statistici */
public invioDatiStatistici = signal<boolean>(false); 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; private wakeLock: any = null;
constructor() { constructor() {
@@ -48,13 +60,10 @@ export class SettingsService {
this.fullscreenMode.set(savedFullscreen === 'true'); this.fullscreenMode.set(savedFullscreen === 'true');
} }
/*
const savedEditor = localStorage.getItem('show-editor'); const savedEditor = localStorage.getItem('show-editor');
if (savedEditor !== null) { if (savedEditor !== null) {
this.showEditor.set(savedEditor === 'true'); this.showEditor.set(savedEditor === 'true');
} }
*/
this.showEditor.set(false); // Funzione temporaneamente disabilitata
const savedAutoAdvance = localStorage.getItem('auto-advance'); const savedAutoAdvance = localStorage.getItem('auto-advance');
if (savedAutoAdvance !== null) { if (savedAutoAdvance !== null) {
@@ -76,6 +85,26 @@ export class SettingsService {
this.invioDatiStatistici.set(savedStats === 'true'); 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) // Sync browser fullscreen state with listeners (supporting vendor prefixes)
const updateFullscreenState = () => { const updateFullscreenState = () => {
const isFs = !!( const isFs = !!(
@@ -97,13 +126,53 @@ export class SettingsService {
}); });
effect(() => { 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(() => { effect(() => {
localStorage.setItem('auto-advance', this.autoAdvance().toString()); 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(() => { effect(() => {
const active = this.keepScreenOn(); const active = this.keepScreenOn();
localStorage.setItem('keep-screen-on', active.toString()); localStorage.setItem('keep-screen-on', active.toString());
@@ -205,4 +274,28 @@ export class SettingsService {
this.invioDatiStatistici.set(newValue); this.invioDatiStatistici.set(newValue);
localStorage.setItem('invio-dati-statistici', newValue.toString()); 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 -4
View File
@@ -4,15 +4,15 @@ import { Injectable, signal, effect } from '@angular/core';
providedIn: 'root' providedIn: 'root'
}) })
export class ThemeService { export class ThemeService {
public highContrast = signal<boolean>(true); public highContrast = signal<boolean>(false);
constructor() { constructor() {
// Load from localStorage // Load from localStorage
const saved = localStorage.getItem('high-contrast'); const saved = localStorage.getItem('high-contrast');
if (saved === 'false') { if (saved === 'true') {
this.highContrast.set(false);
} else {
this.highContrast.set(true); this.highContrast.set(true);
} else {
this.highContrast.set(false);
} }
// Effect to apply class to body // Effect to apply class to body
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.05.18.1550'; export const VERSION = '2026.05.18.1750';