feat: gestione trattini verticali | come accordi e allineamento grafico
This commit is contained in:
@@ -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.
|
||||
@@ -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à. |
|
||||
@@ -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
|
||||
[formats]="allowedFormats"
|
||||
[device]="currentDevice"
|
||||
[autostart]="true"
|
||||
[tryHarder]="false"
|
||||
(camerasFound)="onCamerasFound($event)"
|
||||
(scanSuccess)="onCodeResult($event)">
|
||||
(scanSuccess)="onCodeResult($event)"
|
||||
(scanError)="onScanError($event)"
|
||||
(scanFailure)="onScanFailure($event)">
|
||||
</zxing-scanner>
|
||||
|
||||
<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() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
@@ -5,20 +5,23 @@
|
||||
<img src="assets/icon/favicon.png" class="header-logo">
|
||||
<div class="header-text-group">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
|
||||
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
|
||||
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
|
||||
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
|
||||
</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-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -44,6 +47,10 @@
|
||||
<div class="song-count-card outfit-font">
|
||||
{{ filteredCanti().length }}
|
||||
</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">
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
|
||||
@@ -317,6 +324,23 @@
|
||||
</h2>
|
||||
<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>
|
||||
</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;">
|
||||
<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) }}
|
||||
|
||||
+76
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
<ion-header [translucent]="true" class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient top-toolbar">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title class="outfit-font wrapped-title">
|
||||
<div class="title-main" [style.fontSize.rem]="fontSize() * 1.1">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti">
|
||||
<ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
|
||||
<div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
|
||||
|
||||
<!-- Row 1: Number + Title -->
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;">
|
||||
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
|
||||
</span>
|
||||
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||
<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: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||
<span>{{ canto()?.titolo || 'Player' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
|
||||
<!-- Row 2: Author -->
|
||||
<div *ngIf="!isLandscapeActive()" style="display: block; width: 100%; padding-left: 40px; margin-top: 1px; margin-bottom: 2px;">
|
||||
<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>
|
||||
<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>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only"
|
||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||
[color]="showChords() ? 'secondary' : 'medium'"
|
||||
style="font-size: 1.3rem;">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
|
||||
<!-- Row 3: Badges only -->
|
||||
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal" style="padding-left: 40px; margin-top: 2px; width: 100%;">
|
||||
<div style="display: flex; align-items: center; gap: inherit;">
|
||||
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||
<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-icon name="time-outline"></ion-icon>
|
||||
{{ canto()?.durata }}
|
||||
</span>
|
||||
<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;">
|
||||
<ion-icon name="pulse-outline"></ion-icon>
|
||||
{{ canto()?.bpm }} BPM
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<!-- Audio Toolbar Removed -->
|
||||
@@ -145,6 +153,9 @@
|
||||
<ion-toolbar class="global-player-toolbar glass">
|
||||
<div class="player-content">
|
||||
<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-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -209,6 +220,22 @@
|
||||
</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 -->
|
||||
<div class="group">
|
||||
<ion-button fill="clear" size="small" (click)="restart()">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { FaceDetectorService } from '../../services/face-detector.service';
|
||||
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
public canto = signal<Canto | null>(null);
|
||||
public activeSongId = signal<string | null>(null);
|
||||
public isTitleWrapped = signal<boolean>(false);
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -132,6 +133,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
|
||||
<ion-content class="bg-gradient">
|
||||
<div class="ion-padding">
|
||||
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0">
|
||||
<p>Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
|
||||
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<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 cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||
</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">
|
||||
<ion-item class="custom-input-item select-item">
|
||||
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -118,6 +118,14 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
|
||||
</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-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
@@ -273,6 +281,18 @@
|
||||
</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 -->
|
||||
<div class="settings-group glass ion-margin-bottom">
|
||||
<ion-item class="transparent-item" lines="none">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface Canto {
|
||||
data_update?: string;
|
||||
nonValidato?: boolean;
|
||||
isPersonal?: boolean;
|
||||
durata?: string;
|
||||
bpm?: number;
|
||||
}
|
||||
|
||||
export interface Indice {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(['|', '|', '|', '|']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' : '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ export class SettingsService {
|
||||
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
||||
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 */
|
||||
public enableStandardAutoscroll = signal<boolean>(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);
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.07.13.1941';
|
||||
export const VERSION = '2026.07.14.1946';
|
||||
|
||||
Reference in New Issue
Block a user