diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 82f1667..3c2bfb0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -3,10 +3,11 @@ import { ThemeService } from './services/theme.service'; import { CantiService } from './services/canti.service'; import { SettingsService } from './services/settings.service'; import { VERSION } from './version'; -import { Router, ActivatedRoute } from '@angular/router'; -import { ToastController } from '@ionic/angular'; +import { Router, ActivatedRoute, NavigationStart } from '@angular/router'; +import { ToastController, Platform } from '@ionic/angular'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { filter, first } from 'rxjs/operators'; +import { App } from '@capacitor/app'; @Component({ selector: 'app-root', @@ -19,6 +20,7 @@ export class AppComponent implements OnInit { public cantiService = inject(CantiService); public settingsService = inject(SettingsService); public version = VERSION; + private platform = inject(Platform); private router = inject(Router); private route = inject(ActivatedRoute); private toastCtrl = inject(ToastController); @@ -60,6 +62,27 @@ export class AppComponent implements OnInit { } async ngOnInit() { + // Gestione tasto back per PWA/Browser (intercettando popstate di Angular Router) + this.router.events.subscribe(event => { + if (event instanceof NavigationStart && event.navigationTrigger === 'popstate') { + const targetUrl = event.url.split('?')[0]; + if (targetUrl !== '/home' && targetUrl !== '/') { + this.router.navigate(['/home'], { replaceUrl: true }); + } + } + }); + + // Gestione tasto back hardware per nativo (Capacitor/Cordova) + this.platform.backButton.subscribeWithPriority(9999, () => { + const currentUrl = this.router.url; + const path = currentUrl.split('?')[0]; + if (path !== '/home' && path !== '/') { + this.router.navigate(['/home']); + } else { + App.exitApp(); + } + }); + // Add global horizontal scroll support for wheel on horizontal containers window.addEventListener('wheel', (event: WheelEvent) => { if (Math.abs(event.deltaY) > 0 && Math.abs(event.deltaX) === 0) { diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index d47c7a0..f7a1f21 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -139,6 +139,13 @@
{{ reorderList().length }} + @@ -155,6 +162,9 @@ + + + diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 6578e83..1e1d471 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -383,6 +383,12 @@ ion-title { color: #ffffff !important; background: rgba(255, 255, 255, 0.2) !important; } + .playlist-name-input { + color: #000000 !important; + &::placeholder { + color: rgba(0, 0, 0, 0.6) !important; + } + } } // Custom Item Layout @@ -676,6 +682,30 @@ ion-title { font-size: 1.2rem; } } + + .playlist-name-input { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 10px; + color: white; + margin: 0 4px; + padding: 2px 8px; + font-size: 0.8rem; + width: 120px; + height: 24px; + outline: none; + transition: all 0.2s ease; + + &:focus { + border-color: var(--ion-color-secondary); + background: rgba(255, 255, 255, 0.12); + box-shadow: 0 0 6px rgba(var(--ion-color-secondary-rgb), 0.2); + } + + &::placeholder { + color: rgba(255, 255, 255, 0.4); + } + } } .small-action-btn { diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 43a5040..bdfd962 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -1778,6 +1778,50 @@ export class HomePage implements OnDestroy { this.playlistService.sharePlaylistQR(ids, name, songSettings); } + async cloneActivePlaylist() { + const id = this.playlistService.activePlaylistId(); + const currentName = this.playlistService.activeListName() || 'Playlist'; + const activeIds = this.playlistService.activeListIds(); + + const allPls = this.playlistService.allPlaylists(); + const activePl = allPls.find(p => p.id === id); + const songSettings = activePl ? activePl.songSettings : undefined; + + const alert = await this.alertCtrl.create({ + header: 'Clona Playlist', + inputs: [ + { + name: 'name', + type: 'text', + placeholder: 'Nome della nuova playlist', + value: `${currentName} (Copia)` + } + ], + buttons: [ + { + text: 'Annulla', + role: 'cancel' + }, + { + text: 'Clona', + handler: async (data) => { + if (data.name) { + await this.playlistService.clonePlaylist(data.name, [...activeIds], songSettings); + + const toast = await this.toastCtrl.create({ + message: `Playlist clonata nel profilo: ${data.name}`, + duration: 2500, + color: 'success' + }); + await toast.present(); + } + } + } + ] + }); + await alert.present(); + } + async importPlaylist() { const modal = await this.modalCtrl.create({ component: QrScannerComponent diff --git a/src/app/pages/propose-canto/propose-canto.page.html b/src/app/pages/propose-canto/propose-canto.page.html index a8747c4..6f4e1c4 100644 --- a/src/app/pages/propose-canto/propose-canto.page.html +++ b/src/app/pages/propose-canto/propose-canto.page.html @@ -31,9 +31,19 @@
+ + + + Editor + + + Anteprima + + +
-
+
Titolo del Canto @@ -44,7 +54,7 @@ Momento Liturgico - + {{ lit.tag_name }} @@ -53,7 +63,7 @@ Periodo / Tema - + {{ tem.tag_name }} @@ -103,13 +113,16 @@ Editor Testo
- + - + + + + - +
@@ -142,7 +155,7 @@
-
+
Anteprima Attiva diff --git a/src/app/pages/propose-canto/propose-canto.page.scss b/src/app/pages/propose-canto/propose-canto.page.scss index a58c961..f4f32fc 100644 --- a/src/app/pages/propose-canto/propose-canto.page.scss +++ b/src/app/pages/propose-canto/propose-canto.page.scss @@ -702,3 +702,90 @@ body.high-contrast :host ::ng-deep { } } } + +/* CUSTOM STYLES FOR INTUITIVE MOBILE RESPONSIVENESS */ +.custom-mobile-segment { + display: none; + --background: rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 4px; + border: 1px solid rgba(255, 255, 255, 0.08); + margin-bottom: 16px; + + ion-segment-button { + --color: rgba(255, 255, 255, 0.6); + --color-checked: #ffffff; + --indicator-color: var(--ion-color-secondary); + --border-radius: 8px; + font-weight: 700; + font-family: 'Outfit', sans-serif; + } +} + +body.high-contrast :host ::ng-deep { + .custom-mobile-segment { + --background: #f0f0f0 !important; + background: #f0f0f0 !important; + border: 2px solid #000000 !important; + + ion-segment-button { + --color: #333333 !important; + --color-checked: #000000 !important; + --indicator-color: #000000 !important; + } + } +} + +@media (max-width: 991px) { + .custom-mobile-segment { + display: flex; + } + + .hide-on-mobile { + display: none !important; + } + + .horizontal-toolbar { + flex-wrap: wrap; + overflow-x: visible; + padding-bottom: 0; + + ion-button { + flex-shrink: 1; + } + } +} + +@media (max-width: 576px) { + :host ::ng-deep .category-selectors { + grid-template-columns: 1fr; + gap: 8px; + } + + .editor-wrapper { + .editor-header { + padding: 6px 8px; + flex-wrap: wrap; + + .editor-title-group { + width: 100%; + margin-bottom: 6px; + padding-left: 8px; + } + + .editor-actions { + width: 100%; + display: flex; + justify-content: space-between; + + ion-button { + flex: 1; + height: 40px; + margin: 0; + } + } + } + } +} + diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index 7e8a4a5..46d99fe 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -33,6 +33,7 @@ export class ProposeCantoPage implements OnInit { private alertCtrl = inject(AlertController); showChordsPreview: boolean = true; + activeTab: string = 'editor'; get parsedSections(): ParsedSection[] { return this.lyricsParser.parseAccordi(this.content); @@ -47,6 +48,15 @@ export class ProposeCantoPage implements OnInit { youtubeLink: string = ''; selectedLiturgico: number[] = []; selectedTematico: number[] = []; + + get sortedLiturgico() { + return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name)); + } + + get sortedTematico() { + return [...this.cantiService.indiceTematico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name)); + } + editId: string | null = null; private _content: string = ''; @@ -195,6 +205,194 @@ export class ProposeCantoPage implements OnInit { this.fileInput.nativeElement.click(); } + async deduceChords() { + if (!this.content) return; + + // Salva nello stack degli undo + this.saveToUndoStack(this.content); + + const lines = this.content.split('\n'); + + interface SectionLine { + originalIndex: number; + text: string; + } + + interface Section { + type: 'verse' | 'chorus' | 'other'; + startTag: string | null; + endTag: string | null; + lines: SectionLine[]; + hasChords: boolean; + } + + const sections: Section[] = []; + let currentSection: Section = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + const startMatch = trimmed.match(/^\{(start_verse|start_chorus|start_verse_num|sov|soc)\}/); + const endMatch = trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}'; + + if (startMatch) { + if (currentSection.lines.length > 0 || currentSection.startTag) { + sections.push(currentSection); + } + let type: 'verse' | 'chorus' | 'other' = 'other'; + const tag = startMatch[1]; + if (tag === 'start_verse' || tag === 'start_verse_num' || tag === 'sov') { + type = 'verse'; + } else if (tag === 'start_chorus' || tag === 'soc') { + type = 'chorus'; + } + currentSection = { type, startTag: line, endTag: null, lines: [], hasChords: false }; + } else if (endMatch) { + currentSection.endTag = line; + sections.push(currentSection); + currentSection = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false }; + } else { + if (trimmed === '' && !currentSection.startTag) { + if (currentSection.lines.length > 0) { + sections.push(currentSection); + } + sections.push({ type: 'other', startTag: null, endTag: null, lines: [{ originalIndex: i, text: '' }], hasChords: false }); + currentSection = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false }; + } else { + const hasChords = /\[[^\]]+\]/.test(line); + if (hasChords) { + currentSection.hasChords = true; + } + if (currentSection.lines.length === 0 && !currentSection.startTag && trimmed !== '') { + currentSection.type = 'verse'; + } + const isComment = trimmed.startsWith('{c:') || trimmed.startsWith('{comment:'); + if (!isComment) { + currentSection.lines.push({ originalIndex: i, text: line }); + } + } + } + } + if (currentSection.lines.length > 0 || currentSection.startTag) { + sections.push(currentSection); + } + + const templateVerse = sections.find(s => s.type === 'verse' && s.hasChords); + const templateChorus = sections.find(s => s.type === 'chorus' && s.hasChords); + + let deducedCount = 0; + + const getWords = (text: string) => { + const words: { text: string; start: number; end: number }[] = []; + const regex = /\S+/g; + let match; + while ((match = regex.exec(text)) !== null) { + words.push({ + text: match[0], + start: match.index, + end: match.index + match[0].length + }); + } + return words; + }; + + const alignChords = (templateLine: string, targetLine: string): string => { + if (/\[[^\]]+\]/.test(targetLine)) { + return targetLine; + } + + const parsedTemplate = this.lyricsParser.parseChordLine(templateLine); + const templateClean = parsedTemplate.text; + const targetClean = targetLine.replace(/\[[^\]]*\]/g, ''); + + if (!templateClean.trim() || !targetClean.trim()) { + return targetLine; + } + + const chords: { chord: string; charIndex: number }[] = []; + let charAcc = 0; + parsedTemplate.segments.forEach(seg => { + if (seg.chord) { + chords.push({ chord: seg.chord, charIndex: charAcc }); + } + charAcc += seg.text.length; + }); + + if (chords.length === 0) { + return targetLine; + } + + const templateWords = getWords(templateClean); + const targetWords = getWords(targetClean); + + const insertions: { chord: string; index: number }[] = []; + + chords.forEach(c => { + const ratio = c.charIndex / Math.max(1, templateClean.length); + const wordIdx = templateWords.findIndex(w => Math.abs(w.start - c.charIndex) <= 1); + + let targetIndex = 0; + if (wordIdx !== -1 && templateWords.length > 1 && targetWords.length > 1) { + const wRatio = wordIdx / (templateWords.length - 1); + const targetWIdx = Math.round(wRatio * (targetWords.length - 1)); + targetIndex = targetWords[targetWIdx].start; + } else { + targetIndex = Math.round(ratio * targetClean.length); + } + + insertions.push({ chord: c.chord, index: targetIndex }); + }); + + insertions.sort((a, b) => b.index - a.index); + + let result = targetClean; + insertions.forEach(ins => { + result = result.substring(0, ins.index) + `[${ins.chord}]` + result.substring(ins.index); + }); + + return result; + }; + + const newLines = [...lines]; + + sections.forEach(sec => { + if (!sec.hasChords && sec.type !== 'other') { + const template = sec.type === 'verse' ? templateVerse : templateChorus; + if (template) { + for (let j = 0; j < sec.lines.length; j++) { + const targetSecLine = sec.lines[j]; + if (j < template.lines.length) { + const templateSecLine = template.lines[j]; + const aligned = alignChords(templateSecLine.text, targetSecLine.text); + if (aligned !== targetSecLine.text) { + newLines[targetSecLine.originalIndex] = aligned; + deducedCount++; + } + } + } + } + } + }); + + if (deducedCount > 0) { + this.content = newLines.join('\n'); + const toast = await this.toastController.create({ + message: `Accordi dedotti con successo in ${deducedCount} righe!`, + duration: 3000, + color: 'success' + }); + toast.present(); + } else { + const toast = await this.toastController.create({ + message: 'Nessuna strofa compatibile trovata o accordi giĆ  presenti.', + duration: 3000, + color: 'warning' + }); + toast.present(); + } + } + onDragOver(event: DragEvent) { event.preventDefault(); event.stopPropagation(); @@ -804,11 +1002,11 @@ export class ProposeCantoPage implements OnInit { return italianChordsCount > englishChordsCount; } - isChordWord(text: string, isItalian: boolean): boolean { + isChordWord(text: string, isItalian: boolean, allowLowercase: boolean = false): boolean { if (!text || text.length === 0) return false; const lower = text.toLowerCase(); - // Skip common valid words written purely in lowercase - if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) { + // Skip common valid words written purely in lowercase unless allowLowercase is true + if (!allowLowercase && text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) { return false; } let clean = text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, ''); @@ -943,7 +1141,7 @@ export class ProposeCantoPage implements OnInit { let hasLongNonChord = false; line.forEach(w => { - if (this.isChordWord(w.text, isItalian)) { + if (this.isChordWord(w.text, isItalian, true)) { chordCount++; } else { const clean = w.text.replace(/[.,:;!\?]/g, '').trim(); @@ -1047,10 +1245,11 @@ export class ProposeCantoPage implements OnInit { } // If we see a large vertical gap, close open blocks + const lastProcessedLine = classifiedLines[i]; const currentNext = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null; - if (currentNext) { - const gap = currentNext.yCenter - current.yCenter; - if (gap > avgHeight * 2.5) { + if (currentNext && lastProcessedLine) { + const gap = currentNext.yCenter - lastProcessedLine.yCenter; + if (gap > avgHeight * 3.5) { if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; } if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; } processedLines.push(''); @@ -1120,7 +1319,7 @@ export class ProposeCantoPage implements OnInit { if (!part.trim()) return part; let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, ''); clean = this.sanitizeOcrChord(clean); - if (this.isChordWord(part, isItalian)) { + if (this.isChordWord(part, isItalian, true)) { return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`; } else { return part; @@ -1234,7 +1433,7 @@ export class ProposeCantoPage implements OnInit { let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase(); cleanChord = this.sanitizeOcrChord(cleanChord); - if (this.isChordWord(chord.text, isItalian)) { + if (this.isChordWord(chord.text, isItalian, true)) { wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord, isItalian)}]`; } else { wordResult += wordText.substring(lastCharIndex, charIndex); diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts index 951789c..c9cdf8a 100644 --- a/src/app/services/playlist.service.ts +++ b/src/app/services/playlist.service.ts @@ -257,6 +257,52 @@ export class PlaylistService { this.syncLocalDataToServer(); } + async clonePlaylist(name: string, ids: string[], songSettings?: any) { + await this.initPromise; + const isCommunityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive(); + const key = 'playlists'; + const lastKey = `lastPlaylist_${key}`; + + // Create the cloned playlist + const newPlaylist = { + id: Date.now().toString(), + name, + ids, + songSettings: songSettings || {}, + createdAt: new Date() + }; + + let personalPlaylists: any[] = []; + if (isCommunityActive) { + // If community is active, read the personal playlists from storage + const saved = await this._storage?.get(key); + personalPlaylists = saved || []; + } else { + // If personal context is active, we can use the current signal + personalPlaylists = this.playlists(); + } + + const updated = [newPlaylist, ...personalPlaylists]; + + if (isCommunityActive) { + // Save directly to storage for personal context + await this._storage?.set(key, updated); + await this._storage?.set(`lastPlaylist_playlists`, newPlaylist); + } else { + // Update signal and save to storage + this.playlists.set(updated); + this.lastPlaylist.set(newPlaylist); + this.activeListIds.set(ids); + this.activeListName.set(name); + this.activePlaylistId.set(newPlaylist.id); + await this._storage?.set(key, updated); + await this._storage?.set(lastKey, newPlaylist); + } + + // Sincronizza automaticamente in background + this.syncLocalDataToServer(); + } + async replaceSongIdInPlaylists(oldId: string, newId: string) { await this.initPromise; this.playlists.update(p => p.map(pl => { diff --git a/src/app/version.ts b/src/app/version.ts index 40e9861..34d6e41 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.06.18.0959'; +export const VERSION = '2026.06.18.1346';