diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 125c627..5ad6a37 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,7 +1,7 @@ { "name": "CantiCristiani", "short_name": "CantiCristiani", - "display": "fullscreen", + "display": "standalone", "scope": "/ionic/", "start_url": "/ionic/", "icons": [ diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a4c1d7b..3a566de 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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(); + }); }); } } diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 240e963..6cf56e4 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -271,6 +271,10 @@

{{ canto.autore || 'Autore sconosciuto' }} + + + agg. {{ cantiService.formatUpdateDate(canto.data_update) }} + {{ getEsecuzioniCount(canto.id) }} esecuzioni @@ -280,10 +284,17 @@ Attinenza {{ getMassSuggestionWeight(canto.id_canti) }}%

+ + +
+ + {{ tag }} + +
-
+
diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 1950879..4163fef 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -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; + } +} diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index 65371f9..6101043 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -40,6 +40,20 @@
+ +
+ + + +
+ + V{{ autoscrollSpeed() }} +
+ + + +
+
@@ -61,7 +75,7 @@ - + @@ -141,14 +155,28 @@ -
+
+ +
+ + + +
+ + V{{ autoscrollSpeed() }} +
+ + + +
+ - + @@ -201,7 +229,7 @@ -
+
diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index ac422b8..db88a04 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -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; + } +} diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index b4556f0..e7a3eb7 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -27,6 +27,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public showChords = signal(false); public showSensitivitySlider = signal(false); + /** Autoscroll standard */ + public isAutoscrolling = signal(false); + public autoscrollSpeed = signal(2); + private autoscrollTimer: any = null; + /** Font size scale factor (1.0 = default) */ public fontSize = signal(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(); diff --git a/src/app/pages/propose-canto/propose-canto.page.html b/src/app/pages/propose-canto/propose-canto.page.html index 1b6d28f..f941c26 100644 --- a/src/app/pages/propose-canto/propose-canto.page.html +++ b/src/app/pages/propose-canto/propose-canto.page.html @@ -4,11 +4,6 @@ Mio Canto - - - - - @@ -58,9 +53,28 @@
+
-
- +
+ + {{ group.root }} + +
+
+ + +
+
+ Variazioni {{ selectedRootChord }}: + {{ chord }}
@@ -79,9 +93,6 @@ - - -
@@ -90,8 +101,7 @@ [(ngModel)]="content" placeholder="Scrivi o scansiona..." rows="18" - class="content-textarea" - (click)="openQuickMenu($event)"> + class="content-textarea">
@@ -111,34 +121,8 @@
- - - - -
-
-
Struttura e Tag
-
- - {{ tag.label }} - -
-
-
-
Accordi Comuni
-
- - {{ chord }} - -
-
-
-
-
-
- diff --git a/src/app/pages/propose-canto/propose-canto.page.scss b/src/app/pages/propose-canto/propose-canto.page.scss index e779db3..1b0ba49 100644 --- a/src/app/pages/propose-canto/propose-canto.page.scss +++ b/src/app/pages/propose-canto/propose-canto.page.scss @@ -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; - } -} -.textarea-item { - --background: transparent; - --padding-start: 0; - --padding-end: 0; + .category-selectors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; + } + + .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); + } } } diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index 326a84d..79cf4f7 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -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 = ''; - - 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] 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`); + + 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 { + 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 { + 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 { - 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(); + + 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; diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index 5540b20..84c5e5f 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -57,7 +57,39 @@

Editor e Canti Personali

Abilita aggiunta e gestione "Miei"

- + + + + + +

Visualizza tag

+

Mostra filtri sotto autore nella lista canti

+
+ +
+ + + +

Visualizza data update

+

Mostra data aggiornamento nella lista canti

+
+ +
+ + + +

Autoscroll standard

+

Abilita 3 tasti di autoscroll nel canto

+
+ +
+ + + +

Autoscroll acustico

+

Mostra microfono per scorrimento vocale

+
+
diff --git a/src/app/services/canti.service.ts b/src/app/services/canti.service.ts index 7292c9e..59998f2 100644 --- a/src/app/services/canti.service.ts +++ b/src/app/services/canti.service.ts @@ -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; diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 746c713..141d94c 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -35,6 +35,18 @@ export class SettingsService { /** Invio dati statistici: true = invia pacchetto dati statistici */ public invioDatiStatistici = signal(false); + /** Visualizza tag sotto autore nella lista canti: true = attivo */ + public showTagsInList = signal(false); + + /** Visualizza data update sotto autore nella lista canti: true = attivo */ + public showUpdateDate = signal(false); + + /** Attiva autoscroll standard nel dettaglio canto: true = attivo */ + public enableStandardAutoscroll = signal(false); + + /** Attiva autoscroll acustico nel dettaglio canto: true = attivo */ + public enableAcousticAutoscroll = signal(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()); + } } diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts index 5deee1a..0ac1853 100644 --- a/src/app/services/theme.service.ts +++ b/src/app/services/theme.service.ts @@ -4,15 +4,15 @@ import { Injectable, signal, effect } from '@angular/core'; providedIn: 'root' }) export class ThemeService { - public highContrast = signal(true); + public highContrast = signal(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 diff --git a/src/app/version.ts b/src/app/version.ts index cf70097..583758e 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.05.18.1550'; +export const VERSION = '2026.05.18.1750';