modifiche face offline e 2 secondi di attesa

This commit is contained in:
David Frassi
2026-07-13 17:55:07 +02:00
parent 333858bf48
commit 9f9b164937
20 changed files with 936 additions and 128 deletions
@@ -109,8 +109,24 @@
<!-- EDITOR AREA -->
<div class="editor-wrapper" [class.hc]="isHighContrast">
<div class="editor-header">
<div class="editor-title-group">
<div class="editor-title-group" style="display: flex; align-items: center; gap: 8px;">
<span>Editor Testo</span>
<!-- Transpose buttons in left column, above textarea -->
<div class="editor-transpose-group" style="display: flex; align-items: center; gap: 4px; background: rgba(255, 255, 255, 0.05); padding: 2px 6px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); margin-left: 12px;">
<ion-button fill="clear" size="small" (click)="transposeDown()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
<ion-icon slot="icon-only" name="remove-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
<span class="val outfit-font" style="font-size: 0.85rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 24px; text-align: center;">T:{{ transposeAmount > 0 ? '+' : '' }}{{ transposeAmount }}</span>
<ion-button fill="clear" size="small" (click)="transposeUp()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
<ion-icon slot="icon-only" name="add-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button>
</div>
<!-- Set default key button -->
<ion-button *ngIf="transposeAmount !== 0" fill="outline" color="secondary" size="small" (click)="setDefaultTonalita()" style="margin: 0 0 0 8px; font-size: 10px; font-weight: 700; height: 24px; --border-radius: 6px;">
Imposta tonalità di default
</ion-button>
</div>
<div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica">
@@ -127,25 +143,55 @@
</ion-button>
</div>
</div>
<ion-item class="custom-input-item textarea-item">
<ion-textarea
#contentTextarea
<div class="editor-textarea-container">
<!-- Input textarea -->
<textarea
#nativeTextarea
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
(paste)="onPaste($event)"
spellcheck="false"
autocapitalize="none"
autocomplete="off"
autocorrect="off">
</textarea>
</div>
</div>
<ion-item class="custom-input-item">
<ion-label position="stacked">Autore / Link YouTube</ion-label>
<ion-label position="stacked">Autore</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
</ion-item>
</ion-list>
<!-- YouTube Link and Player Section -->
<div class="custom-input-item youtube-section">
<ion-label position="stacked" style="color: #64ffda; font-family: 'Outfit', sans-serif; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">Link YouTube & Audio Player</ion-label>
<div class="youtube-input-row" style="display: flex; align-items: center; gap: 8px; margin-top: 6px;">
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube" style="flex: 1;"></ion-input>
<ion-button fill="solid" color="secondary" size="small" [disabled]="!youtubeLink" (click)="loadEditorAudio()" style="margin: 0; font-weight: 700;">
Carica
</ion-button>
</div>
<!-- Small Player controls when playing/loaded -->
<div class="editor-mini-player" *ngIf="isAudioLoaded()" style="display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 36px; width: 36px;">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'" style="font-size: 1.6rem;"></ion-icon>
</ion-button>
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: right;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
<ion-range
[min]="0"
[max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)"
color="secondary"
style="margin: 0; padding: 0 4px; flex: 1; --bar-height: 4px; --knob-size: 12px;">
</ion-range>
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: left;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
</div>
</div>
</ion-list>
<div class="action-buttons">
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
<ion-icon slot="start" name="save-outline"></ion-icon>
@@ -153,13 +199,14 @@
</ion-button>
</div>
</div>
<!-- Right Column: Active Preview Pane -->
<div class="preview-column" [class.hide-on-mobile]="activeTab === 'editor'">
<div class="preview-card" [class.hc]="isHighContrast">
<div class="preview-header">
<span class="preview-title">Anteprima Attiva</span>
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn">
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn" style="margin: 0;">
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
</ion-button>
@@ -170,26 +217,28 @@
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
</div>
<div class="preview-lyrics-container">
<div *ngFor="let section of parsedSections"
class="preview-section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="preview-section-label preview-verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines" class="preview-lyric-line">
<!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="preview-chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="preview-chord">{{ seg.chord }}</span>
<span class="preview-seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
<div class="lyrics-view">
<div *ngFor="let section of parsedSections"
class="section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="section-label verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines" class="lyric-line">
<!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
</div>
</div>
</div>
@@ -203,8 +252,6 @@
</div>
</div>
<!-- Hidden inputs -->
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
@@ -94,11 +94,14 @@ body.high-contrast :host ::ng-deep {
}
/* Textarea inside high contrast must have white background and black text */
.content-textarea {
--color: #000000 !important;
color: #000000 !important;
.editor-textarea-container {
background: #ffffff !important;
--background: #ffffff !important;
border: 2px solid #000000 !important;
textarea {
color: #000000 !important;
caret-color: #000000 !important;
}
}
/* Force background of inputs and items to be white with single dark gray border in high contrast */
@@ -267,30 +270,49 @@ body.high-contrast :host ::ng-deep {
}
}
.content-textarea {
--color: #000000 !important;
color: #000000 !important;
background: #ffffff !important;
--background: #ffffff !important;
font-size: 18px !important;
font-weight: 700 !important;
caret-color: #000000;
.editor-textarea-container {
background: #ffffff;
textarea {
color: #000000;
caret-color: #000000;
}
}
}
}
:host ::ng-deep {
.content-textarea {
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
font-weight: 600;
--color: #ffffff !important;
color: #ffffff !important;
--padding-start: 16px;
--padding-end: 16px;
--padding-top: 16px;
--padding-bottom: 16px;
min-height: 400px;
.editor-textarea-container {
position: relative;
width: 100%;
height: 450px;
background: #111111;
border-radius: 0 0 14px 14px;
overflow: hidden;
box-sizing: border-box;
textarea {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0 !important;
padding: 16px !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
font-size: 1.05rem !important;
line-height: 1.6 !important;
color: #ffffff !important;
background: transparent !important;
width: 100%;
height: 100%;
box-sizing: border-box !important;
resize: none;
overflow-y: auto !important;
caret-color: #64ffda !important;
border: none !important;
outline: none !important;
}
}
.category-selectors {
@@ -569,10 +591,16 @@ body.high-contrast :host ::ng-deep {
border-radius: 2px;
}
.preview-section {
margin-bottom: 1.8rem;
position: relative;
.lyrics-view {
max-width: 900px;
margin-left: 0;
text-align: left;
}
.section {
margin-bottom: 2rem;
position: relative;
&.chorus {
background: rgba(var(--ion-color-secondary-rgb), 0.03);
border-left: 3px solid var(--ion-color-secondary);
@@ -581,25 +609,29 @@ body.high-contrast :host ::ng-deep {
border-radius: 0 8px 8px 0;
}
.preview-section-label {
.section-label {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 700;
color: var(--ion-color-secondary);
margin-bottom: 0.4rem;
margin-bottom: 0.5rem;
opacity: 0.7;
letter-spacing: 1px;
}
}
.preview-lyric-line {
margin-bottom: 0.8rem;
.lyric-line {
margin-bottom: 1rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
min-height: 1.5em;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
.preview-chord-segment {
.chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
@@ -609,16 +641,16 @@ body.high-contrast :host ::ng-deep {
margin-right: 0 !important;
}
.preview-chord {
font-size: 0.78em;
.chord {
font-size: 0.75em;
font-weight: 700;
color: var(--ion-color-secondary);
height: 1.25em;
height: 1.2em;
margin-bottom: -0.2em;
padding-right: 0.25em;
}
.preview-seg-text {
.seg-text {
white-space: pre;
&::after {
content: '\200b';
@@ -654,6 +686,16 @@ body.high-contrast :host ::ng-deep {
--color: #000000;
font-weight: 700;
}
.preview-transpose-group {
border-color: #000000 !important;
span {
color: #000000 !important;
}
ion-button {
--color: #000000 !important;
}
}
}
.preview-body {
@@ -672,23 +714,23 @@ body.high-contrast :host ::ng-deep {
}
.preview-lyrics-container {
.preview-section {
.section {
&.chorus {
background: #f5f5f5;
border-left: 3px solid #000000;
}
.preview-section-label {
.section-label {
color: #000000;
}
}
.preview-lyric-line {
.lyric-line {
color: #000000;
}
.preview-chord-segment {
.preview-chord {
.chord-segment {
.chord {
color: #000000;
text-decoration: underline;
font-weight: 800;
@@ -703,6 +745,17 @@ body.high-contrast :host ::ng-deep {
}
}
.youtube-section {
display: flex;
flex-direction: column;
.youtube-input-row {
ion-button {
height: 38px;
}
}
}
/* CUSTOM STYLES FOR INTUITIVE MOBILE RESPONSIVENESS */
.custom-mobile-segment {
display: none;
@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular';
@@ -9,6 +9,7 @@ import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
import { YoutubePlayerService } from '../../services/youtube-player.service';
@Component({
selector: 'app-propose-canto',
@@ -17,8 +18,9 @@ import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser
standalone: true,
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
})
export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
export class ProposeCantoPage implements OnInit, OnDestroy {
@ViewChild('nativeTextarea', { static: false }) nativeTextarea!: ElementRef<HTMLTextAreaElement>;
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
@ViewChild('fileInput', { static: false }) fileInput!: ElementRef;
@@ -31,12 +33,154 @@ export class ProposeCantoPage implements OnInit {
private router = inject(Router);
public lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController);
public youtubePlayerService = inject(YoutubePlayerService);
showChordsPreview: boolean = true;
activeTab: string = 'editor';
transposeAmount: number = 0;
get highlightedHtml(): string {
if (!this.content) return '';
let escaped = this.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
escaped = escaped.replace(/\[([^\]]+)\]/g, (match, chord) => {
const transposed = this.transposeAmount !== 0
? this.lyricsParser.transposeChord(chord, this.transposeAmount)
: chord;
return `<span class="editor-chord">[${transposed}]</span>`;
});
const lines = escaped.split('\n');
let htmlLines: string[] = [];
let inChorus = false;
let inVerse = false;
let inVerseNum = false;
for (const line of lines) {
const trimmed = line.trim();
let currentInChorus = inChorus;
let currentInVerse = inVerse;
let currentInVerseNum = inVerseNum;
let isTagLine = false;
let processedContent = line;
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
inChorus = true;
currentInChorus = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_chorus}' || trimmed === '{eoc}') {
inChorus = false;
currentInChorus = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{start_verse}' || trimmed === '{sov}') {
inVerse = true;
currentInVerse = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_verse}' || trimmed === '{eov}') {
inVerse = false;
currentInVerse = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{start_verse_num}') {
inVerseNum = true;
currentInVerseNum = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_verse_num}') {
inVerseNum = false;
currentInVerseNum = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
}
let classes = ['editor-line'];
if (currentInChorus) classes.push('chorus');
if (currentInVerse) classes.push('verse');
if (currentInVerseNum) classes.push('verse-num');
if (isTagLine) classes.push('tag-line');
const finalContent = processedContent || '&#8203;';
htmlLines.push(`<div class="${classes.join(' ')}">${finalContent}</div>`);
}
return htmlLines.join('\n');
}
get parsedSections(): ParsedSection[] {
return this.lyricsParser.parseAccordi(this.content);
const sections = this.lyricsParser.parseAccordi(this.content);
if (this.showChordsPreview && this.transposeAmount !== 0) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount);
}
return sections;
}
transposeUp() {
this.transposeAmount = (this.transposeAmount + 1) > 12 ? -11 : this.transposeAmount + 1;
}
transposeDown() {
this.transposeAmount = (this.transposeAmount - 1) < -12 ? 11 : this.transposeAmount - 1;
}
setDefaultTonalita() {
if (this.transposeAmount === 0 || !this.content) return;
const chordRegex = /\[([^\]]+)\]/g;
this.content = this.content.replace(chordRegex, (match, chord) => {
const transposed = this.lyricsParser.transposeChord(chord, this.transposeAmount);
return `[${transposed}]`;
});
this.transposeAmount = 0;
}
isAudioLoaded(): boolean {
return !!this.youtubeLink && this.cantiService.getYoutubeId(this.youtubeLink) !== null;
}
loadedYoutubeLink: string = '';
loadEditorAudio() {
if (!this.youtubeLink) return;
const videoId = this.cantiService.getYoutubeId(this.youtubeLink);
if (videoId) {
this.youtubePlayerService.initPlayer(this.editId || 'editing_song', 0, undefined, undefined, this.youtubeLink);
this.loadedYoutubeLink = this.youtubeLink;
}
}
togglePlayPause() {
const currentId = this.editId || 'editing_song';
const isLoaded = this.youtubePlayerService.currentCantoId() === currentId && this.loadedYoutubeLink === this.youtubeLink;
if (!isLoaded && this.youtubeLink) {
this.loadEditorAudio();
} else {
this.youtubePlayerService.togglePlayPause();
}
}
onSeek(event: any) {
const value = event.detail.value;
this.youtubePlayerService.seekTo(value);
}
formatSeconds(seconds: number): string {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
}
toggleChordsPreview() {
@@ -164,8 +308,12 @@ export class ProposeCantoPage implements OnInit {
});
}
ngOnDestroy() {
this.youtubePlayerService.stop();
}
async insertText(tag: string) {
const input = await this.contentTextarea.getInputElement();
const input = this.nativeTextarea.nativeElement;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;