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
+54 -27
View File
@@ -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()">
+154 -42
View File
@@ -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;
+34
View File
@@ -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);
+6 -2
View File
@@ -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)">
+36
View File
@@ -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) {
+20
View File
@@ -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">
+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() {
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);