From 0069c1a55fbd67991a816f451758be35e4df588d Mon Sep 17 00:00:00 2001 From: David Frassi Date: Tue, 14 Jul 2026 19:47:15 +0200 Subject: [PATCH] feat: gestione trattini verticali | come accordi e allineamento grafico --- .agents/AGENTS.md | 3 + parametri_canti_comunita.md | 144 +++++++++++++ scratch/extract_icons.js | 34 +++ .../qr-scanner/qr-scanner.component.html | 6 +- .../qr-scanner/qr-scanner.component.ts | 15 ++ src/app/home/home.page.html | 40 +++- src/app/home/home.page.scss | 100 +++++++-- src/app/home/home.page.ts | 69 +++++- src/app/pages/player/player.page.html | 81 +++++--- src/app/pages/player/player.page.scss | 196 ++++++++++++++---- src/app/pages/player/player.page.ts | 34 +++ src/app/pages/playlist/playlist.page.html | 8 +- src/app/pages/playlist/playlist.page.ts | 36 ++++ .../propose-canto/propose-canto.page.html | 12 ++ .../pages/propose-canto/propose-canto.page.ts | 12 +- src/app/pages/settings/settings.page.html | 20 ++ src/app/pages/settings/settings.page.ts | 21 +- src/app/services/canti.service.ts | 2 + src/app/services/comunita.service.ts | 4 +- .../services/lyrics-parser.service.spec.ts | 19 ++ src/app/services/lyrics-parser.service.ts | 44 +++- src/app/services/my-canti.service.ts | 12 +- src/app/services/playlist.service.ts | 20 +- src/app/services/settings.service.ts | 21 ++ src/app/version.ts | 2 +- 25 files changed, 834 insertions(+), 121 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 parametri_canti_comunita.md create mode 100644 scratch/extract_icons.js diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..2538a8f --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,3 @@ +# Regole del Progetto + +Quando trovi dei trattini verticali `|` tra gli accordi, ricordati di inserirli all'interno della riga degli accordi (cioè racchiusi tra parentesi quadre, ad esempio come accordo `[|]`), non come testo normale. diff --git a/parametri_canti_comunita.md b/parametri_canti_comunita.md new file mode 100644 index 0000000..bf4744f --- /dev/null +++ b/parametri_canti_comunita.md @@ -0,0 +1,144 @@ +# Parametri letti da canti.json e da Comunità + +Questo documento elenca tutti i parametri e i campi strutturati che vengono letti, elaborati e salvati dall'applicazione a partire dal file globale `canti.json` e dalle API/file di configurazione delle **Comunità**. + +--- + +## 1. Parametri da `canti.json` + +Il file `canti.json` rappresenta il database globale dei canti dell'applicazione. Viene scaricato dall'endpoint configurato in `CantiService` (es. `https://www.canticristiani.it/api/canti.json`). + +### Struttura Principale del File + +Il JSON restituito contiene diversi nodi chiave, ciascuno contenente un array `data`: + +```json +{ + "canti": { "data": [...] }, + "indice_liturgico": { "data": [...] }, + "indice_tematico": { "data": [...] }, + "tema": { "data": [...] }, + "canti_eseguiti": { "data": [...] } +} +``` + +#### Nodo `canti.data` (Lista dei Canti) +Ciascun elemento rappresenta un canto e viene mappato nell'interfaccia `Canto`: + +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_canti` | `number` | Identificativo univoco del canto (usato internamente anche come stringa `id`). | +| `titolo` | `string` | Titolo del canto. | +| `testo` | `string` | Testo del canto (può includere indicazioni di accordi). | +| `accordi` | `string` (opzionale) | Accordi musicali associati al canto. | +| `autore` | `string` (opzionale) | Autore o autori del canto. | +| `link_youtube` | `string` (opzionale) | URL o ID del video di YouTube associato al canto. | +| `data_update` | `string` (opzionale) | Data dell'ultimo aggiornamento (formato `YYYY-MM-DD HH:mm:ss`, formattata a schermo in `DD/MM/YYYY`). | +| `nonValidato` | `boolean` (opzionale) | Indica se il canto è in attesa di validazione. | +| `isPersonal` | `boolean` (opzionale) | Flag locale per identificare se si tratta di un canto personale dell'utente. | + +*Nota: Durante l'importazione, viene aggiunto un array `id_momenti: number[]` ricavato dalla tabella pivot `tema.data`.* + +#### Nodo `indice_liturgico.data` e `indice_tematico.data` (Indici/Tag) +Mappati nell'interfaccia `Indice`: + +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_indice_liturgico` / `id_indice_tematico` | `number` | Identificativo dell'indice/momento. | +| `tag_name` | `string` | Nome visualizzato del tag (es. "Ingresso", "Offertorio"). | +| `slug` | `string` | Versione ottimizzata per URL del tag. | + +*Nota: A livello applicativo viene aggiunto il campo `type` con valore `'liturgico'` o `'tematico'`.* + +#### Nodo `tema.data` (Relazione Pivot Canti-Indici) +Utilizzato per associare a ogni canto i rispettivi momenti liturgici o tematici: + +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_canti` | `number` | ID del canto associato. | +| `id_momento` | `number` | ID del momento liturgico/tematico. | + +#### Nodo `canti_eseguiti.data` +Informazioni sull'esecuzione dei canti: + +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_canti` | `number` | ID del canto eseguito. | +| `num` | `number` | Numero di esecuzioni o indicatore di frequenza. | + +--- + +## 2. Parametri dalle Comunità + +I dati di una comunità vengono letti in due modi: tramite l'API di produzione (v3) oppure tramite un file JSON statico di fallback. + +### Opzione A: API di Produzione (`get_all_app_tables`) +Endpoint: `https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables` + +L'API risponde con un oggetto contenente diverse tabelle relazionali: + +#### 1. `parrocchia.data` (Dettagli della Comunità) +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_parrocchia` | `number` | ID interno della parrocchia/comunità. | +| `nome` | `string` | Nome della comunità (es. "Parrocchia S. Maria"). | +| `codice` | `string` | Codice alfanumerico della comunità. | +| `mail` | `string` | Email di riferimento della comunità. | +| `guid_parrocchia` | `string` | GUID univoco. | + +#### 2. `parrocchia_canti.data` (Associazione Canti-Comunità) +Indica quali canti del database generale appartengono al repertorio della comunità: + +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_parrocchia` | `number` | ID della parrocchia. | +| `id_canti` | `number` | ID del canto associato. | +| `num_canto` | `number` | Numero progressivo o di classificazione del canto all'interno della comunità. | + +#### 3. `canti_settings.data` (Impostazioni di Esecuzione personalizzate) +Configurazioni specifiche per l'esecuzione del canto in comunità: + +| Parametro Originale | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_canti` | `number` | ID del canto. | +| `speed` | `number` | Velocità di scorrimento (auto-scroll) consigliata. | +| `tonalita` | `number` | Semitoni di trasposizione (trasporto tonalità) consigliati. | + +#### 4. `canti_personali.data` (Canti personalizzati/inediti della Comunità) +Canti inseriti direttamente dalla comunità e non presenti nel database globale: + +| Parametro Originale | Tipo | Descrizione / Mapping Applicativo | +| :--- | :--- | :--- | +| `id_canti` | `number` | ID del canto personale. | +| `titolo` | `string` | Titolo del canto. | +| `accordi` | `string` | Testo con gli accordi del canto. | +| `autore` | `string` | Autore del canto. | +| `link_youtube` | `string` | Link YouTube. | +| `data_update` | `string` | Data dell'ultimo aggiornamento. | +| `stato` | `number` | Se uguale a `10`, il canto viene contrassegnato come `nonValidato = true`. | + +*Nota: Vengono impostati automaticamente `isPersonal = true` e `id_momenti = []`.* + +#### 5. Scalette della Comunità (`lista_nome` + `lista_esecuzione`) +L'applicazione ricostruisce l'elenco delle scalette (`ComunitaScaletta`): +* **`lista_nome.data`** (Testate delle scalette): + * `id_lista` (`string`/`number`): ID della scaletta. + * `nome` (`string`): Nome della scaletta (es. "Domenica delle Palme"). + * `progr` (`string`): Data o stringa di ordinamento (mappata in `date`). +* **`lista_esecuzione.data`** (Canti contenuti nelle scalette): + * `id_lista` (`string`/`number`): Associazione alla scaletta. + * `id_canti` (`number`): ID del canto. + * `progr` (`number`): Ordine progressivo del canto all'interno della scaletta (usato per l'ordinamento). + +--- + +### Opzione B: Fallback Statico (`comunita_[codice].json`) +Se l'API di produzione non è raggiungibile, viene tentato il download di un file statico (es. `comunita_123456.json`) dall'origine del sito. + +La struttura attesa per questo file è molto più semplice: + +| Parametro JSON | Tipo | Descrizione | +| :--- | :--- | :--- | +| `id_comunita` | `string` | Codice identificativo della comunità. | +| `nome_comunita` | `string` | Nome leggibile della comunità. | +| `canti` | `(number \| string)[]` | Array contenente gli ID di tutti i canti associati a questa comunità. | diff --git a/scratch/extract_icons.js b/scratch/extract_icons.js new file mode 100644 index 0000000..76b0d3f --- /dev/null +++ b/scratch/extract_icons.js @@ -0,0 +1,34 @@ +const fs = require('fs'); +const path = require('path'); + +function walkDir(dir, callback) { + fs.readdirSync(dir).forEach(f => { + let dirPath = path.join(dir, f); + let isDirectory = fs.statSync(dirPath).isDirectory(); + if (isDirectory) { + walkDir(dirPath, callback); + } else { + callback(dirPath); + } + }); +} + +const icons = new Set(); + +walkDir(path.join(__dirname, '../src/app'), (filePath) => { + if (filePath.endsWith('.html') || filePath.endsWith('.ts')) { + const content = fs.readFileSync(filePath, 'utf8'); + // Match name="icon-name" + const nameMatches = content.matchAll(/name=["']([a-zA-Z0-9-]+)["']/g); + for (const match of nameMatches) { + icons.add(match[1]); + } + // Match [name]="... ? 'icon-a' : 'icon-b'" + const ternaryMatches = content.matchAll(/'([a-zA-Z0-9-]+-outline|[a-zA-Z0-9-]+-sharp|[a-zA-Z0-9-]+)'/g); + for (const match of ternaryMatches) { + icons.add(match[1]); + } + } +}); + +console.log(JSON.stringify(Array.from(icons).sort(), null, 2)); diff --git a/src/app/components/qr-scanner/qr-scanner.component.html b/src/app/components/qr-scanner/qr-scanner.component.html index cc8d944..a030978 100644 --- a/src/app/components/qr-scanner/qr-scanner.component.html +++ b/src/app/components/qr-scanner/qr-scanner.component.html @@ -12,8 +12,12 @@ + (scanSuccess)="onCodeResult($event)" + (scanError)="onScanError($event)" + (scanFailure)="onScanFailure($event)">
diff --git a/src/app/components/qr-scanner/qr-scanner.component.ts b/src/app/components/qr-scanner/qr-scanner.component.ts index 7556f9c..bd671e3 100644 --- a/src/app/components/qr-scanner/qr-scanner.component.ts +++ b/src/app/components/qr-scanner/qr-scanner.component.ts @@ -55,6 +55,21 @@ export class QrScannerComponent { } } + onScanError(error: any) { + // Silenzia e ignora gli errori interni della libreria zxing + try { + if (error) { + // Previeni la propagazione a livello di window/console + if (typeof error.preventDefault === 'function') error.preventDefault(); + if (typeof error.stopPropagation === 'function') error.stopPropagation(); + } + } catch(e) {} + } + + onScanFailure(failure: any) { + // Ignora i tentativi falliti di decodifica frame per frame + } + cancel() { this.modalCtrl.dismiss(); } diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 3a72ea1..a091511 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -5,20 +5,23 @@
{{ appName }} - + v{{ version }} - {{ settingsService.userName() }} - - -
- - - - + + + + + + + + + + @@ -44,6 +47,10 @@
{{ filteredCanti().length }}
+
+ + {{ totalPlaylistDuration() }} +

{{ canto.autore || 'Autore sconosciuto' }} +

+

+ + + + {{ canto.durata }} + + + + {{ canto.bpm }} BPM + + + + {{ getSongTonality(canto) }} + + agg. {{ cantiService.formatUpdateDate(canto.data_update) }} diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 1e1d471..346258f 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -90,6 +90,13 @@ backdrop-filter: blur(10px); flex-shrink: 0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + + .playlist-total-duration { + font-weight: 400; + opacity: 0.75; + font-size: 0.8rem; + letter-spacing: 0.01em; + } } .filter-buttons { @@ -160,14 +167,72 @@ ion-title { letter-spacing: 1px; color: var(--ion-color-secondary); padding-inline: 0; - - .add-btn, .settings-btn { - --color: var(--ion-color-secondary); - --padding-start: 8px; - --padding-end: 8px; - font-size: 1.2rem; -} +} +ion-header { + padding-top: 6px; +} + +ion-buttons[slot="end"] { + gap: 0px; +} + +.update-btn, .add-btn, .settings-btn { + --color: var(--ion-color-secondary); + --padding-start: 0; + --padding-end: 0; + margin-left: -4px; + margin-right: 0; + width: 38px; + min-width: 38px; + height: 38px; + + ion-icon { + font-size: 1.6rem; + } +} + +.update-btn { + animation: pulse-update 2.5s infinite ease-in-out; +} + +@keyframes pulse-update { + 0% { + transform: scale(1); + opacity: 0.9; + } + 50% { + transform: scale(1.1); + opacity: 1; + filter: drop-shadow(0 0 6px rgba(var(--ion-color-secondary-rgb), 0.6)); + } + 100% { + transform: scale(1); + opacity: 0.9; + } +} + +@media (max-width: 375px) { + ion-buttons[slot="end"] { + gap: 0px; + } + .update-btn, .add-btn, .settings-btn { + --padding-start: 0; + --padding-end: 0; + margin-left: -5px; + width: 34px; + min-width: 34px; + height: 34px; + + ion-icon { + font-size: 1.4rem; + } + } + .settings-btn { + margin-right: 2px; + } +} +ion-title { .header-logo-wrapper { display: flex; align-items: center; @@ -209,7 +274,7 @@ ion-title { .settings-btn { - margin-right: 16px; + margin-right: 8px; } .special-list-banner { @@ -775,25 +840,14 @@ ion-title { align-items: center; gap: 4px; - .play-btn { + .play-btn, .skip-btn, .close-btn { --padding-start: 0; --padding-end: 0; - height: 44px; - width: 44px; + height: 40px; + width: 40px; ion-icon { - font-size: 2.2rem; - } - } - - .skip-btn, .close-btn { - --padding-start: 0; - --padding-end: 0; - height: 36px; - width: 36px; - - ion-icon { - font-size: 1.4rem; + font-size: 1.6rem; } } diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index ca795cf..0fabee1 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -19,6 +19,7 @@ import { environment } from '../../environments/environment'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { filter, first } from 'rxjs/operators'; import { showFullscreenUpdateOverlay } from '../app.component'; +import { LyricsParserService } from '../services/lyrics-parser.service'; @Component({ selector: 'app-home', @@ -93,6 +94,7 @@ export class HomePage implements OnDestroy { public playlistService = inject(PlaylistService); public settingsService = inject(SettingsService); public cantiLettureService = inject(CantiLettureService); + public lyricsParser = inject(LyricsParserService); private router = inject(Router); private route = inject(ActivatedRoute); private modalCtrl = inject(ModalController); @@ -462,6 +464,47 @@ export class HomePage implements OnDestroy { return this.filteredCanti().slice(0, this.limit()); }); + public totalPlaylistDuration = computed(() => { + if (this.activeFilterType() !== 'playlist' || this.playlistService.activeListName() === null) return ''; + const songs = this.filteredCanti(); + if (songs.length === 0) return ''; + + let totalSeconds = 0; + for (const song of songs) { + if (song.durata) { + totalSeconds += this.parseDuration(song.durata); + } + } + + if (totalSeconds === 0) return ''; + return this.formatTotalDuration(totalSeconds); + }); + + private parseDuration(dur: string): number { + if (!dur) return 0; + const parts = dur.split(':').map(Number); + if (parts.some(isNaN)) return 0; + if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } else if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return 0; + } + + private formatTotalDuration(seconds: number): string { + const hh = Math.floor(seconds / 3600); + const mm = Math.floor((seconds % 3600) / 60); + const ss = seconds % 60; + + const pad = (n: number) => n.toString().padStart(2, '0'); + + if (hh > 0) { + return `${pad(hh)}:${pad(mm)}:${pad(ss)}`; + } + return `${pad(mm)}:${pad(ss)}`; + } + constructor() { // Check if there is an update available this.checkUpdateStatus(); @@ -634,6 +677,15 @@ export class HomePage implements OnDestroy { }); } + ionViewWillEnter() { + // Gestione dati QR scansionati e passati via router state dalle Impostazioni + const navigation = this.router.getCurrentNavigation(); + const state = navigation?.extras.state as { scannedQrData?: string } | undefined; + if (state?.scannedQrData) { + this.handleScannedData(state.scannedQrData); + } + } + async handleImport(base64: string) { @@ -784,7 +836,9 @@ export class HomePage implements OnDestroy { accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), link_youtube: item.link_youtube || '', id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], - nonValidato: true + nonValidato: true, + durata: item.durata || '', + bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined }; await this.playlistService.saveRemoteShareCanto(canto); @@ -842,7 +896,9 @@ export class HomePage implements OnDestroy { accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), autore: item.autore || '', link_youtube: item.link_youtube || '', - id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] + id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], + durata: item.durata || '', + bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined })); // 2. Reconstruct playlists @@ -957,7 +1013,9 @@ export class HomePage implements OnDestroy { accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), autore: item.autore || '', link_youtube: item.link_youtube || '', - id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] + id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], + durata: item.durata || '', + bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined })); if (customSongs.length > 0) { @@ -1958,6 +2016,11 @@ export class HomePage implements OnDestroy { return index !== -1 ? index + 1 : 0; } + getSongTonality(canto: any): string | null { + if (!canto) return null; + return this.lyricsParser.deduceTonality(canto.accordi || canto.testo || ''); + } + toggleComunitaFilter() { if (this.comunitaService.comunitaCode()) { this.comunitaService.isFilterActive.update(v => !v); diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index e2652d1..8212ee8 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -1,37 +1,45 @@ - - - - - -

- + +
+ + +
+ {{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }} - - {{ getCommunitySongNumber(canto()) }} + + {{ getCommunitySongNumber(canto()) }} {{ canto()?.titolo || 'Player' }}
- - -
- + + +
+ + {{ canto()?.autore || 'Autore sconosciuto' }} +
- - - - - - - - - - - + + +
+
+ + + + {{ canto()?.durata }} + + + + {{ canto()?.bpm }} BPM + + + + {{ tonality() }} + + +
+
+
@@ -145,6 +153,9 @@
+ + + @@ -209,6 +220,22 @@
+ +
+ + + + + + + + + + +
+
diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index 5d32535..4b9d0df 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -3,6 +3,13 @@ background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); } +.canto-title { + transition: font-size 0.15s ease; + &.small-title { + font-size: 1.12rem !important; + } +} + .outfit-font { font-family: 'Outfit', sans-serif; } @@ -20,40 +27,56 @@ text-align: left !important; } + .canto-number { + position: relative; + width: 32px; + height: 32px; + line-height: 28px; + text-align: center; + background: rgba(var(--ion-color-secondary-rgb), 0.1); + color: var(--ion-color-secondary); + font-size: 0.85rem; + border-radius: 8px; + font-weight: 700; + border: 2px solid var(--ion-color-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + &.clickable { + cursor: pointer; + transition: all 0.2s ease; + &:active { transform: scale(0.9); } + } + + &.selected { + background: var(--ion-color-secondary); + color: #000; + border-color: var(--ion-color-secondary); + box-shadow: 0 0 10px rgba(var(--ion-color-secondary-rgb), 0.4); + } + } + .title-main { display: flex; align-items: center; justify-content: flex-start; gap: 12px; + font-size: 1.15rem; // Fisso: non influenzato dallo zoom del testo font-weight: 700; color: var(--ion-color-secondary); white-space: normal; line-height: 1.2; text-align: left; - .canto-number { - font-size: 0.85em; - background: rgba(var(--ion-color-secondary-rgb), 0.15); - padding: 2px 10px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 6px; - border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3); - flex-shrink: 0; - - &.clickable { - cursor: pointer; - transition: all 0.2s ease; - &:active { transform: scale(0.9); } - } - - &.selected { - background: var(--ion-color-secondary); - color: #000; - border-color: var(--ion-color-secondary); - box-shadow: 0 0 10px rgba(var(--ion-color-secondary-rgb), 0.4); - } + .song-author { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.55); + font-weight: 400; + display: inline-block; + font-family: 'Outfit', sans-serif; } .title-text { @@ -61,6 +84,102 @@ text-align: left; } } + + .scroll-horizontal { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 12px; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + .song-duration-badge, .song-bpm-badge, .song-tonality-badge { + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 3px; + height: 26px; + flex-shrink: 0; + + ion-icon { + font-size: 1.05rem; + } + } + + .header-action-btn { + margin: 0; + --padding-start: 4px; + --padding-end: 4px; + height: 38px; + width: 38px; + flex-shrink: 0; + + ion-icon { + font-size: 1.65rem; + } + } + + // Responsive scaling based on viewport width + @media (max-width: 480px) { + gap: 4px; + + .song-duration-badge, .song-bpm-badge, .song-tonality-badge { + font-size: 0.68rem; + padding: 1px 3px; + height: 22px; + gap: 2px; + + ion-icon { + font-size: 0.9rem; + } + } + + .header-action-btn { + height: 32px; + width: 32px; + --padding-start: 0px; + --padding-end: 0px; + + ion-icon { + font-size: 1.35rem; + } + } + } + + @media (max-width: 360px) { + gap: 2px; + + .song-duration-badge, .song-bpm-badge, .song-tonality-badge { + font-size: 0.62rem; + padding: 1px 2px; + height: 20px; + gap: 1px; + + ion-icon { + font-size: 0.8rem; + } + } + + .header-action-btn { + height: 28px; + width: 28px; + --padding-start: 0px; + --padding-end: 0px; + + ion-icon { + font-size: 1.15rem; + } + } + } + } } // Content and Lyrics @@ -105,25 +224,14 @@ align-items: center; gap: 4px; - .play-btn { + .play-btn, .skip-btn, .close-btn { --padding-start: 0; --padding-end: 0; - height: 44px; - width: 44px; + height: 40px; + width: 40px; ion-icon { - font-size: 2.2rem; - } - } - - .skip-btn, .close-btn { - --padding-start: 0; - --padding-end: 0; - height: 36px; - width: 36px; - - ion-icon { - font-size: 1.4rem; + font-size: 1.6rem; } } @@ -144,7 +252,7 @@ } .section { - margin-bottom: 2rem; + margin-bottom: 1.2rem; position: relative; &.chorus { @@ -160,15 +268,15 @@ text-transform: uppercase; font-weight: 700; color: var(--ion-color-secondary); - margin-bottom: 0.5rem; + margin-bottom: 0.25rem; opacity: 0.7; letter-spacing: 1px; } } .lyric-line { - margin-bottom: 1rem; - line-height: 1.6; + margin-bottom: 0.4rem; + line-height: 1.35; color: rgba(255, 255, 255, 0.9); transition: all 0.3s ease; min-height: 1.5em; @@ -731,6 +839,10 @@ ion-content.full-screen-content { } :host-context(body.high-contrast) { + .title-main .song-author { + color: rgba(0, 0, 0, 0.6) !important; + } + .slim-toolbar { --background: #ffffff !important; background: #ffffff !important; diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index 4693e23..c844091 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -26,6 +26,7 @@ import { FaceDetectorService } from '../../services/face-detector.service'; export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public canto = signal(null); public activeSongId = signal(null); + public isTitleWrapped = signal(false); @HostListener('window:keydown', ['$event']) handleKeyDown(event: KeyboardEvent) { @@ -132,6 +133,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public transposeAmount = signal(0); + public tonality = computed(() => { + const song = this.canto(); + if (!song) return null; + const baseKey = this.lyricsParser.deduceTonality(song.accordi || song.testo || ''); + if (!baseKey) return null; + + const amount = this.transposeAmount(); + if (amount === 0) return baseKey; + return this.lyricsParser.transposeChord(baseKey, amount); + }); + public currentLineIndex = signal(0); public math = Math; public youtubePlayerService = inject(YoutubePlayerService); @@ -216,6 +228,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (this.playlistService.autoPlayPlaylist()) { setTimeout(() => this.initPlayer(c.id), 500); } + this.isTitleWrapped.set(false); + setTimeout(() => this.checkTitleWrap(), 150); } }); @@ -366,6 +380,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { onResize(event: any) { this.windowLandscape.set(window.innerWidth > window.innerHeight); this.checkLandscapeZoom(); + this.checkTitleWrap(); } private isLandscape(): boolean { @@ -488,8 +503,23 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + + checkTitleWrap() { + const el = document.querySelector('.canto-title') as HTMLElement; + if (!el) return; + + this.isTitleWrapped.set(false); + + setTimeout(() => { + if (el.scrollHeight > 30) { + this.isTitleWrapped.set(true); + } + }, 50); + } + ngAfterViewInit() { this.checkLandscapeZoom(); + this.checkTitleWrap(); const gestureX = this.gestureCtrl.create({ el: this.el.nativeElement, direction: 'x', @@ -686,6 +716,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.youtubePlayerService.seekTo(event.detail.value); } + goHome() { + this.router.navigate(['/home']); + } + restart() { this.currentLineIndex.set(0); this.youtubePlayerService.seekTo(0); diff --git a/src/app/pages/playlist/playlist.page.html b/src/app/pages/playlist/playlist.page.html index 70cc59a..6162f4f 100644 --- a/src/app/pages/playlist/playlist.page.html +++ b/src/app/pages/playlist/playlist.page.html @@ -14,8 +14,12 @@
-
-

Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.

+
+

Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.

+
+ + {{ totalDuration }} +
diff --git a/src/app/pages/playlist/playlist.page.ts b/src/app/pages/playlist/playlist.page.ts index 57f0b2a..068cefb 100644 --- a/src/app/pages/playlist/playlist.page.ts +++ b/src/app/pages/playlist/playlist.page.ts @@ -51,6 +51,42 @@ export class PlaylistPage { public qrCodeImage: string | null = null; public savedPlaylistName: string | null = null; + get totalDuration(): string { + const songs = this.localSongs; + if (songs.length === 0) return ''; + let totalSeconds = 0; + for (const song of songs) { + if (song && song.durata) { + totalSeconds += this.parseDuration(song.durata); + } + } + if (totalSeconds === 0) return ''; + return this.formatTotalDuration(totalSeconds); + } + + private parseDuration(dur: string): number { + if (!dur) return 0; + const parts = dur.split(':').map(Number); + if (parts.some(isNaN)) return 0; + if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } else if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return 0; + } + + private formatTotalDuration(seconds: number): string { + const hh = Math.floor(seconds / 3600); + const mm = Math.floor((seconds % 3600) / 60); + const ss = seconds % 60; + const pad = (n: number) => n.toString().padStart(2, '0'); + if (hh > 0) { + return `${pad(hh)}:${pad(mm)}:${pad(ss)}`; + } + return `${pad(mm)}:${pad(ss)}`; + } + constructor() { // Initial copy to allow local reordering this.localSongs = [...this.selectedSongs()]; diff --git a/src/app/pages/propose-canto/propose-canto.page.html b/src/app/pages/propose-canto/propose-canto.page.html index b0e9229..a8b3b50 100644 --- a/src/app/pages/propose-canto/propose-canto.page.html +++ b/src/app/pages/propose-canto/propose-canto.page.html @@ -50,6 +50,18 @@ +
+ + Durata (mm:ss) + + + + + BPM (Tempo) + + +
+
Momento Liturgico diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index 76854e3..0c7a163 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -192,6 +192,8 @@ export class ProposeCantoPage implements OnInit, OnDestroy { youtubeLink: string = ''; selectedLiturgico: number[] = []; selectedTematico: number[] = []; + durata: string = ''; + bpm: number | null = null; get sortedLiturgico() { return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name)); @@ -296,6 +298,8 @@ export class ProposeCantoPage implements OnInit, OnDestroy { this.author = song.autore || ''; this.youtubeLink = song.link_youtube || ''; this.content = song.accordi || song.testo || ''; + this.durata = song.durata || ''; + this.bpm = song.bpm !== undefined ? song.bpm : null; // Pre-populate liturgico and tematico lists const litIds = this.cantiService.indiceLiturgico().map(m => m.id); @@ -1702,7 +1706,9 @@ export class ProposeCantoPage implements OnInit, OnDestroy { link_youtube: this.youtubeLink, testo: this.content, accordi: this.content, - id_momenti: id_momenti + id_momenti: id_momenti, + durata: this.durata || undefined, + bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined }); // 2. Clone/convert remote playlist to local personal playlist @@ -1735,7 +1741,9 @@ export class ProposeCantoPage implements OnInit, OnDestroy { link_youtube: this.youtubeLink, testo: this.content, accordi: this.content, // Save to both fields for compatibility - id_momenti: id_momenti + id_momenti: id_momenti, + durata: this.durata || undefined, + bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined }); if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) { diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index eb62850..e79851f 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -118,6 +118,14 @@ + + + +

Visualizza durata, bpm e tonalità

+

Mostra queste info sotto autore / titolo

+
+ +
@@ -273,6 +281,18 @@
+ +
+ + + +

Importa Playlist via QR

+

Inquadra il QR di una playlist condivisa per importarla

+
+ +
+
+
diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index 32997c9..999500b 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -77,6 +77,19 @@ export class SettingsPage { } } + async importPlaylistViaQr() { + const modal = await this.modalCtrl.create({ + component: QrScannerComponent + }); + await modal.present(); + + const { data } = await modal.onWillDismiss(); + if (data) { + // Naviga alla home passando il dato via state per il processing multi-scopo + this.router.navigate(['/home'], { state: { scannedQrData: data } }); + } + } + async manualRestore() { const alert = await this.alertCtrl.create({ header: 'Ripristina con Codice', @@ -159,7 +172,9 @@ export class SettingsPage { accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), autore: item.autore || '', link_youtube: item.link_youtube || '', - id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] + id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], + durata: item.durata || '', + bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined })); if (customSongs.length > 0) { @@ -347,7 +362,9 @@ export class SettingsPage { accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), autore: item.autore || '', link_youtube: item.link_youtube || '', - id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] + id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], + durata: item.durata || '', + bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined })); this.myCantiService.myCanti.set(customSongs); diff --git a/src/app/services/canti.service.ts b/src/app/services/canti.service.ts index 27daea9..7c88b36 100644 --- a/src/app/services/canti.service.ts +++ b/src/app/services/canti.service.ts @@ -15,6 +15,8 @@ export interface Canto { data_update?: string; nonValidato?: boolean; isPersonal?: boolean; + durata?: string; + bpm?: number; } export interface Indice { diff --git a/src/app/services/comunita.service.ts b/src/app/services/comunita.service.ts index 23baeee..6bf9ddb 100644 --- a/src/app/services/comunita.service.ts +++ b/src/app/services/comunita.service.ts @@ -187,7 +187,9 @@ export class ComunitaService { id_momenti: [], data_update: cp.data_update || '', nonValidato: Number(cp.stato) === 10, - isPersonal: true + isPersonal: true, + durata: cp.durata || '', + bpm: cp.bpm !== undefined && cp.bpm !== null ? Number(cp.bpm) : undefined })); this.comunitaCode.set(trimmedCode); diff --git a/src/app/services/lyrics-parser.service.spec.ts b/src/app/services/lyrics-parser.service.spec.ts index d598ea4..05f8161 100644 --- a/src/app/services/lyrics-parser.service.spec.ts +++ b/src/app/services/lyrics-parser.service.spec.ts @@ -126,4 +126,23 @@ Abba Padre! expect(sections[1].type).toBe('chorus'); expect(sections[2].type).toBe('verse'); }); + + it('should parse vertical bars | as chords', () => { + const rawSong = ` +[SOL] | [RE] | [DO] | [RE] +| | | | +`; + const sections = service.parseAccordi(rawSong); + expect(sections.length).toBe(1); + const line1 = sections[0].lines[0]; + const line2 = sections[0].lines[1]; + + // Check first line: SOL | RE | DO | RE + // All components should be parsed as chords + expect(line1.segments.map(s => s.chord)).toEqual(['SOL', '|', 'RE', '|', 'DO', '|', 'RE']); + expect(line1.segments.map(s => s.text.trim())).toEqual(['', '', '', '', '', '', '']); + + // Check second line: | | | | + expect(line2.segments.map(s => s.chord)).toEqual(['|', '|', '|', '|']); + }); }); diff --git a/src/app/services/lyrics-parser.service.ts b/src/app/services/lyrics-parser.service.ts index 5658c86..0bcd630 100644 --- a/src/app/services/lyrics-parser.service.ts +++ b/src/app/services/lyrics-parser.service.ts @@ -194,9 +194,13 @@ export class LyricsParserService { * sei Re Gesù[SOL] → text "sei Re Gesù" then chord "SOL" with empty text */ parseChordLine(line: string): ParsedLine { + // Treat vertical bars (|) as chords instead of text + let prepared = line.replace(/\[\|\]/g, '|'); + prepared = prepared.replace(/\|/g, '[|]'); + const segments: ChordSegment[] = []; // Clean up non-breaking spaces - const cleaned = line.replace(/\u00a0/g, ' ').trim(); + const cleaned = prepared.replace(/\u00a0/g, ' ').trim(); // Regex to match [CHORD] tags and text between them const chordRegex = /\[([^\]]+)\]/g; @@ -251,6 +255,7 @@ export class LyricsParserService { */ transposeChord(chord: string, semitones: number): string { if (!chord) return chord; + if (chord === '|') return '|'; // Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm' chord = chord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => { @@ -362,5 +367,42 @@ export class LyricsParserService { return endsWithNonSpace && startsWithNonSpace; } + + /** + * Deduce the musical key (tonality) of the song by looking at the first chord. + */ + deduceTonality(raw: string): string | null { + if (!raw) return null; + const matches = [...raw.matchAll(/\[([^\]]+)\]/g)]; + if (matches.length === 0) return null; + + // Get the first chord + let firstChord = matches[0][1].trim(); + if (firstChord.includes('/')) { + firstChord = firstChord.split('/')[0].trim(); + } + + // Normalize minor indicators (uppercase M/N to m) + firstChord = firstChord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => { + return p1 + (p2 || '') + 'm'; + }); + + // Find the root chord name matching the scales + const possibleRoots = [...this.scale, ...this.flatScale].sort((a, b) => b.length - a.length); + let root = ''; + const upperChord = firstChord.toUpperCase(); + for (const r of possibleRoots) { + if (upperChord.startsWith(r.toUpperCase())) { + root = r; + break; + } + } + + if (!root) return null; + + // Check if it is a minor chord + const isMinor = firstChord.toLowerCase().includes('m') || firstChord.includes('-'); + return root + (isMinor ? 'm' : ''); + } } diff --git a/src/app/services/my-canti.service.ts b/src/app/services/my-canti.service.ts index 2690426..6feb121 100644 --- a/src/app/services/my-canti.service.ts +++ b/src/app/services/my-canti.service.ts @@ -56,7 +56,9 @@ export class MyCantiService { accordi: canto.accordi !== undefined ? canto.accordi : c.accordi, autore: canto.autore !== undefined ? canto.autore : c.autore, link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube, - id_momenti: canto.id_momenti || c.id_momenti + id_momenti: canto.id_momenti || c.id_momenti, + durata: canto.durata !== undefined ? canto.durata : c.durata, + bpm: canto.bpm !== undefined ? canto.bpm : c.bpm }; return targetCanto; } @@ -74,7 +76,9 @@ export class MyCantiService { accordi: canto.accordi, autore: canto.autore, link_youtube: canto.link_youtube, - id_momenti: canto.id_momenti || [] + id_momenti: canto.id_momenti || [], + durata: canto.durata, + bpm: canto.bpm }; updated.push(targetCanto); } @@ -88,7 +92,9 @@ export class MyCantiService { accordi: canto.accordi, autore: canto.autore, link_youtube: canto.link_youtube, - id_momenti: canto.id_momenti || [] + id_momenti: canto.id_momenti || [], + durata: canto.durata, + bpm: canto.bpm }; updated = [...current, targetCanto]; } diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts index d29385b..bc943de 100644 --- a/src/app/services/playlist.service.ts +++ b/src/app/services/playlist.service.ts @@ -216,7 +216,9 @@ export class PlaylistService { accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), autore: item.autore || '', link_youtube: item.link_youtube || '', - id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [] + id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [], + durata: item.durata || '', + bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined })); const playlists = remoteJson @@ -486,7 +488,9 @@ export class PlaylistService { accordi: c.accordi || '', momenti: c.id_momenti?.map(id => String(id)) || [], periodi: [] as string[], - testo: c.testo || '' + testo: c.testo || '', + durata: c.durata || '', + bpm: c.bpm !== undefined ? c.bpm : null }); }); @@ -500,7 +504,9 @@ export class PlaylistService { accordi: c.accordi || '', momenti: c.id_momenti?.map(id => String(id)) || [], periodi: [] as string[], - testo: c.testo || '' + testo: c.testo || '', + durata: c.durata || '', + bpm: c.bpm !== undefined ? c.bpm : null }); }); @@ -514,7 +520,9 @@ export class PlaylistService { accordi: c.accordi || '', momenti: c.id_momenti?.map((id: any) => String(id)) || [], periodi: [] as string[], - testo: c.testo || '' + testo: c.testo || '', + durata: c.durata || '', + bpm: c.bpm !== undefined ? c.bpm : null }); }); @@ -528,7 +536,9 @@ export class PlaylistService { accordi: c.accordi || '', momenti: c.id_momenti?.map((id: any) => String(id)) || [], periodi: [] as string[], - testo: c.testo || '' + testo: c.testo || '', + durata: c.durata || '', + bpm: c.bpm !== undefined ? c.bpm : null }); }); diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 54cb055..1218c15 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -41,6 +41,9 @@ export class SettingsService { /** Visualizza data update sotto autore nella lista canti: true = attivo */ public showUpdateDate = signal(true); + /** Visualizza durata, bpm e tonalità sotto autore / titolo: true = attivo */ + public showDurationBpmTonality = signal(true); + /** Attiva autoscroll standard nel dettaglio canto: true = attivo */ public enableStandardAutoscroll = signal(false); @@ -155,6 +158,7 @@ export class SettingsService { localStorage.setItem('invio-dati-statistici', 'false'); localStorage.setItem('show-tags-in-list', 'true'); localStorage.setItem('show-update-date', 'true'); + localStorage.setItem('show-duration-bpm-tonality', 'true'); localStorage.setItem('enable-standard-autoscroll', 'false'); localStorage.setItem('enable-visual-autoscroll', 'true'); localStorage.setItem('chord-notation-preference', 'diesis'); @@ -225,6 +229,13 @@ export class SettingsService { this.showUpdateDate.set(true); } + const savedShowDurBpmTon = localStorage.getItem('show-duration-bpm-tonality'); + if (savedShowDurBpmTon !== null) { + this.showDurationBpmTonality.set(savedShowDurBpmTon === 'true'); + } else { + this.showDurationBpmTonality.set(true); + } + const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll'); if (savedStandardAutoscroll !== null) { this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true'); @@ -312,6 +323,10 @@ export class SettingsService { localStorage.setItem('show-update-date', this.showUpdateDate().toString()); }); + effect(() => { + localStorage.setItem('show-duration-bpm-tonality', this.showDurationBpmTonality().toString()); + }); + effect(() => { localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString()); }); @@ -451,6 +466,12 @@ export class SettingsService { localStorage.setItem('show-update-date', newValue.toString()); } + toggleShowDurationBpmTonality() { + const newValue = !this.showDurationBpmTonality(); + this.showDurationBpmTonality.set(newValue); + localStorage.setItem('show-duration-bpm-tonality', newValue.toString()); + } + toggleStandardAutoscroll() { const newValue = !this.enableStandardAutoscroll(); this.enableStandardAutoscroll.set(newValue); diff --git a/src/app/version.ts b/src/app/version.ts index f564fdd..faeb443 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.07.13.1941'; +export const VERSION = '2026.07.14.1946';