feat: gestione trattini verticali | come accordi e allineamento grafico

This commit is contained in:
David Frassi
2026-07-14 19:47:15 +02:00
parent ad9286002b
commit 0069c1a55f
25 changed files with 834 additions and 121 deletions
+3
View File
@@ -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.
+144
View File
@@ -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à. |
+34
View File
@@ -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));
@@ -12,8 +12,12 @@
<zxing-scanner <zxing-scanner
[formats]="allowedFormats" [formats]="allowedFormats"
[device]="currentDevice" [device]="currentDevice"
[autostart]="true"
[tryHarder]="false"
(camerasFound)="onCamerasFound($event)" (camerasFound)="onCamerasFound($event)"
(scanSuccess)="onCodeResult($event)"> (scanSuccess)="onCodeResult($event)"
(scanError)="onScanError($event)"
(scanFailure)="onScanFailure($event)">
</zxing-scanner> </zxing-scanner>
<div class="scan-overlay"> <div class="scan-overlay">
@@ -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() { cancel() {
this.modalCtrl.dismiss(); this.modalCtrl.dismiss();
} }
+32 -8
View File
@@ -5,20 +5,23 @@
<img src="assets/icon/favicon.png" class="header-logo"> <img src="assets/icon/favicon.png" class="header-logo">
<div class="header-text-group"> <div class="header-text-group">
<span class="app-name">{{ appName }}</span> <span class="app-name">{{ appName }}</span>
<span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;"> <span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span> v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span>
<span *ngIf="hasUpdateAvailable()" style="display: inline-flex; align-items: center; justify-content: center; padding: 4px; margin-left: 2px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 24px; height: 24px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3);">
<ion-icon name="refresh-outline" style="font-size: 1.15rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
</span> </span>
</div> </div>
</div> </div>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
<ion-button (click)="importPlaylist()" class="add-btn"> <ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon> <ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
</ion-button> </ion-button>
</ng-container>
<ng-template #showQrBtn>
<ion-button (click)="importPlaylist()" class="add-btn">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button>
</ng-template>
<ion-button routerLink="/settings" class="settings-btn"> <ion-button routerLink="/settings" class="settings-btn">
<ion-icon slot="icon-only" name="settings-outline"></ion-icon> <ion-icon slot="icon-only" name="settings-outline"></ion-icon>
</ion-button> </ion-button>
@@ -44,6 +47,10 @@
<div class="song-count-card outfit-font"> <div class="song-count-card outfit-font">
{{ filteredCanti().length }} {{ filteredCanti().length }}
</div> </div>
<div *ngIf="playlistService.activeListName() !== null && totalPlaylistDuration()" class="song-count-card outfit-font">
<ion-icon name="time-outline" style="margin-right: 4px; font-size: 1rem; color: var(--ion-color-secondary);"></ion-icon>
<span>{{ totalPlaylistDuration() }}</span>
</div>
<div class="filter-buttons"> <div class="filter-buttons">
<ion-button <ion-button
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'" [fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
@@ -317,6 +324,23 @@
</h2> </h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span>{{ canto.autore || 'Autore sconosciuto' }}</span> <span>{{ canto.autore || 'Autore sconosciuto' }}</span>
</p>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px;"
*ngIf="(settingsService.showDurationBpmTonality() && (canto.durata || canto.bpm || getSongTonality(canto))) || (settingsService.showUpdateDate() && canto.data_update) || (showTopTen() && getEsecuzioniCount(canto.id) !== null) || (showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null)">
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
<span *ngIf="canto.durata" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="time-outline" style="font-size: 0.75rem;"></ion-icon>
{{ canto.durata }}
</span>
<span *ngIf="canto.bpm" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="pulse-outline" style="font-size: 0.75rem;"></ion-icon>
{{ canto.bpm }} BPM
</span>
<span *ngIf="getSongTonality(canto)" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
<ion-icon name="musical-note-outline" style="font-size: 0.75rem;"></ion-icon>
{{ getSongTonality(canto) }}
</span>
</ng-container>
<span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;"> <span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;">
<ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon> <ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon>
agg. {{ cantiService.formatUpdateDate(canto.data_update) }} agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
+76 -22
View File
@@ -90,6 +90,13 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 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 { .filter-buttons {
@@ -160,14 +167,72 @@ ion-title {
letter-spacing: 1px; letter-spacing: 1px;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
padding-inline: 0; 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 { .header-logo-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -209,7 +274,7 @@ ion-title {
.settings-btn { .settings-btn {
margin-right: 16px; margin-right: 8px;
} }
.special-list-banner { .special-list-banner {
@@ -775,25 +840,14 @@ ion-title {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
.play-btn { .play-btn, .skip-btn, .close-btn {
--padding-start: 0; --padding-start: 0;
--padding-end: 0; --padding-end: 0;
height: 44px; height: 40px;
width: 44px; width: 40px;
ion-icon { ion-icon {
font-size: 2.2rem; font-size: 1.6rem;
}
}
.skip-btn, .close-btn {
--padding-start: 0;
--padding-end: 0;
height: 36px;
width: 36px;
ion-icon {
font-size: 1.4rem;
} }
} }
+66 -3
View File
@@ -19,6 +19,7 @@ import { environment } from '../../environments/environment';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators'; import { filter, first } from 'rxjs/operators';
import { showFullscreenUpdateOverlay } from '../app.component'; import { showFullscreenUpdateOverlay } from '../app.component';
import { LyricsParserService } from '../services/lyrics-parser.service';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@@ -93,6 +94,7 @@ export class HomePage implements OnDestroy {
public playlistService = inject(PlaylistService); public playlistService = inject(PlaylistService);
public settingsService = inject(SettingsService); public settingsService = inject(SettingsService);
public cantiLettureService = inject(CantiLettureService); public cantiLettureService = inject(CantiLettureService);
public lyricsParser = inject(LyricsParserService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private modalCtrl = inject(ModalController); private modalCtrl = inject(ModalController);
@@ -462,6 +464,47 @@ export class HomePage implements OnDestroy {
return this.filteredCanti().slice(0, this.limit()); 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() { constructor() {
// Check if there is an update available // Check if there is an update available
this.checkUpdateStatus(); 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) { async handleImport(base64: string) {
@@ -784,7 +836,9 @@ export class HomePage implements OnDestroy {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
link_youtube: item.link_youtube || '', 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)) || [],
nonValidato: true nonValidato: true,
durata: item.durata || '',
bpm: item.bpm !== undefined && item.bpm !== null ? Number(item.bpm) : undefined
}; };
await this.playlistService.saveRemoteShareCanto(canto); await this.playlistService.saveRemoteShareCanto(canto);
@@ -842,7 +896,9 @@ export class HomePage implements OnDestroy {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', 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 // 2. Reconstruct playlists
@@ -957,7 +1013,9 @@ export class HomePage implements OnDestroy {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', 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) { if (customSongs.length > 0) {
@@ -1958,6 +2016,11 @@ export class HomePage implements OnDestroy {
return index !== -1 ? index + 1 : 0; return index !== -1 ? index + 1 : 0;
} }
getSongTonality(canto: any): string | null {
if (!canto) return null;
return this.lyricsParser.deduceTonality(canto.accordi || canto.testo || '');
}
toggleComunitaFilter() { toggleComunitaFilter() {
if (this.comunitaService.comunitaCode()) { if (this.comunitaService.comunitaCode()) {
this.comunitaService.isFilterActive.update(v => !v); this.comunitaService.isFilterActive.update(v => !v);
+54 -27
View File
@@ -1,37 +1,45 @@
<ion-header [translucent]="true" class="ion-no-border"> <ion-header [translucent]="true" class="ion-no-border">
<ion-toolbar class="bg-gradient top-toolbar"> <ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
<ion-buttons slot="start"> <div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons> <!-- Row 1: Number + Title -->
<ion-title class="outfit-font wrapped-title"> <div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1"> <span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;">
<span class="canto-number" *ngIf="canto()?.id_canti">
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }} {{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
</span> </span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <span class="canto-title" style="font-weight: 700; font-size: 1.15rem; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span> <span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span> <span>{{ canto()?.titolo || 'Player' }}</span>
</span> </span>
</div> </div>
</ion-title>
<ion-buttons slot="end"> <!-- Row 2: Author -->
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()"> <div *ngIf="!isLandscapeActive()" style="display: block; width: 100%; padding-left: 40px; margin-top: 1px; margin-bottom: 2px;">
<ion-icon name="cloud-offline-outline"></ion-icon> <span class="song-author" style="font-size: 0.95rem; color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
{{ canto()?.autore || 'Autore sconosciuto' }}
</span>
</div> </div>
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon> <!-- Row 3: Badges only -->
</ion-button> <div *ngIf="!isLandscapeActive()" class="scroll-horizontal" style="padding-left: 40px; margin-top: 2px; width: 100%;">
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> <div style="display: flex; align-items: center; gap: inherit;">
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon> <ng-container *ngIf="settingsService.showDurationBpmTonality()">
</ion-button> <span *ngIf="canto()?.durata" class="song-duration-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> <ion-icon name="time-outline"></ion-icon>
<ion-icon slot="icon-only" {{ canto()?.durata }}
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'" </span>
[color]="showChords() ? 'secondary' : 'medium'" <span *ngIf="canto()?.bpm" class="song-bpm-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
style="font-size: 1.3rem;"> <ion-icon name="pulse-outline"></ion-icon>
</ion-icon> {{ canto()?.bpm }} BPM
</ion-button> </span>
</ion-buttons> <span *ngIf="tonality()" class="song-tonality-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
<ion-icon name="musical-note-outline"></ion-icon>
{{ tonality() }}
</span>
</ng-container>
</div>
</div>
</div>
</ion-toolbar> </ion-toolbar>
<!-- Audio Toolbar Removed --> <!-- Audio Toolbar Removed -->
@@ -145,6 +153,9 @@
<ion-toolbar class="global-player-toolbar glass"> <ion-toolbar class="global-player-toolbar glass">
<div class="player-content"> <div class="player-content">
<div class="controls-row"> <div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="goHome()" class="skip-btn">
<ion-icon slot="icon-only" name="home-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn"> <ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
<ion-icon slot="icon-only" name="chevron-back"></ion-icon> <ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button> </ion-button>
@@ -209,6 +220,22 @@
</div> </div>
</div> </div>
<!-- Action Buttons (edit, condividi, accordi) -->
<div class="group">
<ion-button fill="clear" size="small" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()">
<ion-icon slot="icon-only" name="create-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="shareCanto()">
<ion-icon slot="icon-only" name="share-social-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="toggleChords()">
<ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
[color]="showChords() ? 'secondary' : 'medium'">
</ion-icon>
</ion-button>
</div>
<!-- Navigation --> <!-- Navigation -->
<div class="group"> <div class="group">
<ion-button fill="clear" size="small" (click)="restart()"> <ion-button fill="clear" size="small" (click)="restart()">
+154 -42
View File
@@ -3,6 +3,13 @@
background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); 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 { .outfit-font {
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
} }
@@ -20,40 +27,56 @@
text-align: left !important; 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 { .title-main {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 12px; gap: 12px;
font-size: 1.15rem; // Fisso: non influenzato dallo zoom del testo
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
white-space: normal; white-space: normal;
line-height: 1.2; line-height: 1.2;
text-align: left; text-align: left;
.canto-number { .song-author {
font-size: 0.85em; font-size: 0.75rem;
background: rgba(var(--ion-color-secondary-rgb), 0.15); color: rgba(255, 255, 255, 0.55);
padding: 2px 10px; font-weight: 400;
display: inline-flex; display: inline-block;
align-items: center; font-family: 'Outfit', sans-serif;
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);
}
} }
.title-text { .title-text {
@@ -61,6 +84,102 @@
text-align: left; 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 // Content and Lyrics
@@ -105,25 +224,14 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
.play-btn { .play-btn, .skip-btn, .close-btn {
--padding-start: 0; --padding-start: 0;
--padding-end: 0; --padding-end: 0;
height: 44px; height: 40px;
width: 44px; width: 40px;
ion-icon { ion-icon {
font-size: 2.2rem; font-size: 1.6rem;
}
}
.skip-btn, .close-btn {
--padding-start: 0;
--padding-end: 0;
height: 36px;
width: 36px;
ion-icon {
font-size: 1.4rem;
} }
} }
@@ -144,7 +252,7 @@
} }
.section { .section {
margin-bottom: 2rem; margin-bottom: 1.2rem;
position: relative; position: relative;
&.chorus { &.chorus {
@@ -160,15 +268,15 @@
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
opacity: 0.7; opacity: 0.7;
letter-spacing: 1px; letter-spacing: 1px;
} }
} }
.lyric-line { .lyric-line {
margin-bottom: 1rem; margin-bottom: 0.4rem;
line-height: 1.6; line-height: 1.35;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 1.5em; min-height: 1.5em;
@@ -731,6 +839,10 @@ ion-content.full-screen-content {
} }
:host-context(body.high-contrast) { :host-context(body.high-contrast) {
.title-main .song-author {
color: rgba(0, 0, 0, 0.6) !important;
}
.slim-toolbar { .slim-toolbar {
--background: #ffffff !important; --background: #ffffff !important;
background: #ffffff !important; background: #ffffff !important;
+34
View File
@@ -26,6 +26,7 @@ import { FaceDetectorService } from '../../services/face-detector.service';
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public canto = signal<Canto | null>(null); public canto = signal<Canto | null>(null);
public activeSongId = signal<string | null>(null); public activeSongId = signal<string | null>(null);
public isTitleWrapped = signal<boolean>(false);
@HostListener('window:keydown', ['$event']) @HostListener('window:keydown', ['$event'])
handleKeyDown(event: KeyboardEvent) { handleKeyDown(event: KeyboardEvent) {
@@ -132,6 +133,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public transposeAmount = signal<number>(0); public transposeAmount = signal<number>(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<number>(0); public currentLineIndex = signal<number>(0);
public math = Math; public math = Math;
public youtubePlayerService = inject(YoutubePlayerService); public youtubePlayerService = inject(YoutubePlayerService);
@@ -216,6 +228,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (this.playlistService.autoPlayPlaylist()) { if (this.playlistService.autoPlayPlaylist()) {
setTimeout(() => this.initPlayer(c.id), 500); 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) { onResize(event: any) {
this.windowLandscape.set(window.innerWidth > window.innerHeight); this.windowLandscape.set(window.innerWidth > window.innerHeight);
this.checkLandscapeZoom(); this.checkLandscapeZoom();
this.checkTitleWrap();
} }
private isLandscape(): boolean { 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() { ngAfterViewInit() {
this.checkLandscapeZoom(); this.checkLandscapeZoom();
this.checkTitleWrap();
const gestureX = this.gestureCtrl.create({ const gestureX = this.gestureCtrl.create({
el: this.el.nativeElement, el: this.el.nativeElement,
direction: 'x', direction: 'x',
@@ -686,6 +716,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.youtubePlayerService.seekTo(event.detail.value); this.youtubePlayerService.seekTo(event.detail.value);
} }
goHome() {
this.router.navigate(['/home']);
}
restart() { restart() {
this.currentLineIndex.set(0); this.currentLineIndex.set(0);
this.youtubePlayerService.seekTo(0); this.youtubePlayerService.seekTo(0);
+6 -2
View File
@@ -14,8 +14,12 @@
<ion-content class="bg-gradient"> <ion-content class="bg-gradient">
<div class="ion-padding"> <div class="ion-padding">
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0"> <div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
<p>Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p> <p style="margin: 0;">Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
<div *ngIf="totalDuration" style="background: rgba(var(--ion-color-secondary-rgb), 0.25); border: 1px solid var(--ion-color-secondary); padding: 4px 10px; border-radius: 10px; font-weight: 800; color: var(--ion-color-secondary); display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-family: 'Outfit', sans-serif; white-space: nowrap; margin-left: 12px;">
<ion-icon name="time-outline" style="font-size: 1rem;"></ion-icon>
{{ totalDuration }}
</div>
</div> </div>
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)"> <div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
+36
View File
@@ -51,6 +51,42 @@ export class PlaylistPage {
public qrCodeImage: string | null = null; public qrCodeImage: string | null = null;
public savedPlaylistName: 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() { constructor() {
// Initial copy to allow local reordering // Initial copy to allow local reordering
this.localSongs = [...this.selectedSongs()]; this.localSongs = [...this.selectedSongs()];
@@ -50,6 +50,18 @@
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input> <ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
</ion-item> </ion-item>
<div class="category-selectors">
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Durata (mm:ss)</ion-label>
<ion-input [(ngModel)]="durata" placeholder="Es: 03:45"></ion-input>
</ion-item>
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">BPM (Tempo)</ion-label>
<ion-input type="number" [(ngModel)]="bpm" placeholder="Es: 120"></ion-input>
</ion-item>
</div>
<div class="category-selectors"> <div class="category-selectors">
<ion-item class="custom-input-item select-item"> <ion-item class="custom-input-item select-item">
<ion-label position="stacked">Momento Liturgico</ion-label> <ion-label position="stacked">Momento Liturgico</ion-label>
@@ -192,6 +192,8 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
youtubeLink: string = ''; youtubeLink: string = '';
selectedLiturgico: number[] = []; selectedLiturgico: number[] = [];
selectedTematico: number[] = []; selectedTematico: number[] = [];
durata: string = '';
bpm: number | null = null;
get sortedLiturgico() { get sortedLiturgico() {
return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name)); 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.author = song.autore || '';
this.youtubeLink = song.link_youtube || ''; this.youtubeLink = song.link_youtube || '';
this.content = song.accordi || song.testo || ''; this.content = song.accordi || song.testo || '';
this.durata = song.durata || '';
this.bpm = song.bpm !== undefined ? song.bpm : null;
// Pre-populate liturgico and tematico lists // Pre-populate liturgico and tematico lists
const litIds = this.cantiService.indiceLiturgico().map(m => m.id); const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
@@ -1702,7 +1706,9 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
link_youtube: this.youtubeLink, link_youtube: this.youtubeLink,
testo: this.content, testo: this.content,
accordi: 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 // 2. Clone/convert remote playlist to local personal playlist
@@ -1735,7 +1741,9 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
link_youtube: this.youtubeLink, link_youtube: this.youtubeLink,
testo: this.content, testo: this.content,
accordi: this.content, // Save to both fields for compatibility 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) { if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) {
+20
View File
@@ -118,6 +118,14 @@
</ion-label> </ion-label>
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle> <ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
</ion-item> </ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="musical-notes-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Visualizza durata, bpm e tonalità</h2>
<p class="settings-item-subtitle">Mostra queste info sotto autore / titolo</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.showDurationBpmTonality()" (ionChange)="settingsService.toggleShowDurationBpmTonality()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon> <ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font"> <ion-label class="outfit-font">
@@ -273,6 +281,18 @@
</div> </div>
</div> </div>
<!-- Importa Playlist -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none" button (click)="importPlaylistViaQr()">
<ion-icon name="qr-code-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Importa Playlist via QR</h2>
<p class="settings-item-subtitle">Inquadra il QR di una playlist condivisa per importarla</p>
</ion-label>
<ion-icon name="chevron-forward-outline" slot="end" color="medium" style="font-size: 1rem;"></ion-icon>
</ion-item>
</div>
<!-- Invio Dati Statistici --> <!-- Invio Dati Statistici -->
<div class="settings-group glass ion-margin-bottom"> <div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
+19 -2
View File
@@ -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() { async manualRestore() {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Ripristina con Codice', header: 'Ripristina con Codice',
@@ -159,7 +172,9 @@ export class SettingsPage {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', 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) { if (customSongs.length > 0) {
@@ -347,7 +362,9 @@ export class SettingsPage {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', 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); this.myCantiService.myCanti.set(customSongs);
+2
View File
@@ -15,6 +15,8 @@ export interface Canto {
data_update?: string; data_update?: string;
nonValidato?: boolean; nonValidato?: boolean;
isPersonal?: boolean; isPersonal?: boolean;
durata?: string;
bpm?: number;
} }
export interface Indice { export interface Indice {
+3 -1
View File
@@ -187,7 +187,9 @@ export class ComunitaService {
id_momenti: [], id_momenti: [],
data_update: cp.data_update || '', data_update: cp.data_update || '',
nonValidato: Number(cp.stato) === 10, 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); this.comunitaCode.set(trimmedCode);
@@ -126,4 +126,23 @@ Abba Padre!
expect(sections[1].type).toBe('chorus'); expect(sections[1].type).toBe('chorus');
expect(sections[2].type).toBe('verse'); 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(['|', '|', '|', '|']);
});
}); });
+43 -1
View File
@@ -194,9 +194,13 @@ export class LyricsParserService {
* sei Re Gesù[SOL] text "sei Re Gesù" then chord "SOL" with empty text * sei Re Gesù[SOL] text "sei Re Gesù" then chord "SOL" with empty text
*/ */
parseChordLine(line: string): ParsedLine { parseChordLine(line: string): ParsedLine {
// Treat vertical bars (|) as chords instead of text
let prepared = line.replace(/\[\|\]/g, '|');
prepared = prepared.replace(/\|/g, '[|]');
const segments: ChordSegment[] = []; const segments: ChordSegment[] = [];
// Clean up non-breaking spaces // 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 // Regex to match [CHORD] tags and text between them
const chordRegex = /\[([^\]]+)\]/g; const chordRegex = /\[([^\]]+)\]/g;
@@ -251,6 +255,7 @@ export class LyricsParserService {
*/ */
transposeChord(chord: string, semitones: number): string { transposeChord(chord: string, semitones: number): string {
if (!chord) return chord; 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' // 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) => { 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; 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' : '');
}
} }
+9 -3
View File
@@ -56,7 +56,9 @@ export class MyCantiService {
accordi: canto.accordi !== undefined ? canto.accordi : c.accordi, accordi: canto.accordi !== undefined ? canto.accordi : c.accordi,
autore: canto.autore !== undefined ? canto.autore : c.autore, autore: canto.autore !== undefined ? canto.autore : c.autore,
link_youtube: canto.link_youtube !== undefined ? canto.link_youtube : c.link_youtube, 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; return targetCanto;
} }
@@ -74,7 +76,9 @@ export class MyCantiService {
accordi: canto.accordi, accordi: canto.accordi,
autore: canto.autore, autore: canto.autore,
link_youtube: canto.link_youtube, link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || [] id_momenti: canto.id_momenti || [],
durata: canto.durata,
bpm: canto.bpm
}; };
updated.push(targetCanto); updated.push(targetCanto);
} }
@@ -88,7 +92,9 @@ export class MyCantiService {
accordi: canto.accordi, accordi: canto.accordi,
autore: canto.autore, autore: canto.autore,
link_youtube: canto.link_youtube, link_youtube: canto.link_youtube,
id_momenti: canto.id_momenti || [] id_momenti: canto.id_momenti || [],
durata: canto.durata,
bpm: canto.bpm
}; };
updated = [...current, targetCanto]; updated = [...current, targetCanto];
} }
+15 -5
View File
@@ -216,7 +216,9 @@ export class PlaylistService {
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined), accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
autore: item.autore || '', autore: item.autore || '',
link_youtube: item.link_youtube || '', 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 const playlists = remoteJson
@@ -486,7 +488,9 @@ export class PlaylistService {
accordi: c.accordi || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [], momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[], 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 || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [], momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[], 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 || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map((id: any) => String(id)) || [], momenti: c.id_momenti?.map((id: any) => String(id)) || [],
periodi: [] as string[], 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 || '', accordi: c.accordi || '',
momenti: c.id_momenti?.map((id: any) => String(id)) || [], momenti: c.id_momenti?.map((id: any) => String(id)) || [],
periodi: [] as string[], periodi: [] as string[],
testo: c.testo || '' testo: c.testo || '',
durata: c.durata || '',
bpm: c.bpm !== undefined ? c.bpm : null
}); });
}); });
+21
View File
@@ -41,6 +41,9 @@ export class SettingsService {
/** Visualizza data update sotto autore nella lista canti: true = attivo */ /** Visualizza data update sotto autore nella lista canti: true = attivo */
public showUpdateDate = signal<boolean>(true); public showUpdateDate = signal<boolean>(true);
/** Visualizza durata, bpm e tonalità sotto autore / titolo: true = attivo */
public showDurationBpmTonality = signal<boolean>(true);
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */ /** Attiva autoscroll standard nel dettaglio canto: true = attivo */
public enableStandardAutoscroll = signal<boolean>(false); public enableStandardAutoscroll = signal<boolean>(false);
@@ -155,6 +158,7 @@ export class SettingsService {
localStorage.setItem('invio-dati-statistici', 'false'); localStorage.setItem('invio-dati-statistici', 'false');
localStorage.setItem('show-tags-in-list', 'true'); localStorage.setItem('show-tags-in-list', 'true');
localStorage.setItem('show-update-date', 'true'); localStorage.setItem('show-update-date', 'true');
localStorage.setItem('show-duration-bpm-tonality', 'true');
localStorage.setItem('enable-standard-autoscroll', 'false'); localStorage.setItem('enable-standard-autoscroll', 'false');
localStorage.setItem('enable-visual-autoscroll', 'true'); localStorage.setItem('enable-visual-autoscroll', 'true');
localStorage.setItem('chord-notation-preference', 'diesis'); localStorage.setItem('chord-notation-preference', 'diesis');
@@ -225,6 +229,13 @@ export class SettingsService {
this.showUpdateDate.set(true); 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'); const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
if (savedStandardAutoscroll !== null) { if (savedStandardAutoscroll !== null) {
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true'); this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
@@ -312,6 +323,10 @@ export class SettingsService {
localStorage.setItem('show-update-date', this.showUpdateDate().toString()); localStorage.setItem('show-update-date', this.showUpdateDate().toString());
}); });
effect(() => {
localStorage.setItem('show-duration-bpm-tonality', this.showDurationBpmTonality().toString());
});
effect(() => { effect(() => {
localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString()); localStorage.setItem('enable-standard-autoscroll', this.enableStandardAutoscroll().toString());
}); });
@@ -451,6 +466,12 @@ export class SettingsService {
localStorage.setItem('show-update-date', newValue.toString()); 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() { toggleStandardAutoscroll() {
const newValue = !this.enableStandardAutoscroll(); const newValue = !this.enableStandardAutoscroll();
this.enableStandardAutoscroll.set(newValue); this.enableStandardAutoscroll.set(newValue);
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.07.13.1941'; export const VERSION = '2026.07.14.1946';