feat: condivisione e importazione automatica del codice comunità nelle playlist con priorità tonalità e autoscroll e rifinitura UI

This commit is contained in:
David Frassi
2026-05-23 16:53:09 +02:00
parent b220167794
commit 960c73fbd0
20 changed files with 680 additions and 122 deletions
+35 -3
View File
@@ -16,14 +16,42 @@ export class DisplayPage implements OnInit, OnDestroy {
public showChords = signal<boolean>(false);
public fontSize = signal<number>(1.0);
public currentLineIndex = signal<number>(0);
public transposeAmount = signal<number>(0);
public parsedSections = computed<ParsedSection[]>(() => {
const c = this.canto();
if (!c) return [];
if (this.showChords() && c.accordi) {
return this.lyricsParser.parseAccordi(c.accordi);
let sections: ParsedSection[];
const hasChordsInText = !c.accordi && c.testo?.includes('[');
if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) {
sections = this.lyricsParser.parseAccordi(c.accordi || c.testo);
} else {
sections = this.lyricsParser.parseText(c.testo);
}
return this.lyricsParser.parseText(c.testo);
if (sections.length === 0 && c.testo) {
const fallbackLines = c.testo.split('\n')
.filter(l => l.trim().length > 0)
.map(l => ({
text: l.trim(),
segments: [{ text: l.trim() }]
}));
if (fallbackLines.length > 0) {
sections = [{
type: 'verse',
lines: fallbackLines
}];
}
}
if (this.showChords()) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount());
}
return sections;
});
/** Get the current line text and surrounding lines from flat index */
@@ -94,6 +122,7 @@ export class DisplayPage implements OnInit, OnDestroy {
}
if (event.data.type === 'SYNC_CANTO') {
this.activeSongId.set(event.data.id);
this.transposeAmount.set(0);
}
if (event.data.type === 'SYNC_CHORDS') {
this.showChords.set(event.data.showChords);
@@ -101,6 +130,9 @@ export class DisplayPage implements OnInit, OnDestroy {
if (event.data.type === 'SYNC_FONT') {
this.fontSize.set(event.data.fontSize);
}
if (event.data.type === 'SYNC_TRANSPOSE') {
this.transposeAmount.set(event.data.amount);
}
};
}
+23 -55
View File
@@ -1,5 +1,19 @@
<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">
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }}
</span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; 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>
@@ -12,21 +26,6 @@
</ion-icon>
</ion-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">
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }}
</span>
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span>
</span>
<span *ngIf="canto()?.nonValidato" class="non-validato-badge">Non Validato</span>
</div>
</ion-title>
<ion-buttons slot="start">
<ion-back-button text="" defaultHref="/home" color="secondary"></ion-back-button>
</ion-buttons>
</ion-toolbar>
<!-- Audio Toolbar Removed -->
@@ -44,50 +43,19 @@
<!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container">
<!-- Autoscroll group in Landscape Side -->
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()" style="gap: 4px; padding: 4px 0; background: rgba(255,255,255,0.05); border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); width: 44px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10" style="height: 32px; margin: 0;">
<ion-icon name="add" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
<!-- Karaoke Line Advancer (Avanzatore di riga) -->
<div class="side-group" style="margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;">
<ion-button fill="clear" (click)="restart()" style="height: 48px; margin: 0;">
<ion-icon name="arrow-up-circle" color="secondary" style="font-size: 1.8rem;"></ion-icon>
</ion-button>
<div (click)="toggleAutoscroll()" style="cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'" style="font-size: 1.4rem;"></ion-icon>
<span style="font-size: 0.65rem; font-weight: 700; color: var(--ion-color-secondary);">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1" style="height: 32px; margin: 0;">
<ion-icon name="remove" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0" style="height: 48px; margin: 0;">
<ion-icon name="chevron-up" color="secondary" style="font-size: 1.8rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1" style="height: 48px; margin: 0;">
<ion-icon name="chevron-down" color="secondary" style="font-size: 1.8rem;"></ion-icon>
</ion-button>
</div>
<div class="side-group equidistant-group">
<!-- Navigation -->
<ion-button fill="clear" (click)="restart()">
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon name="chevron-up" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon name="chevron-down" color="secondary"></ion-icon>
</ion-button>
<!-- Zoom -->
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= 5.0">
<ion-icon name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= 0.6">
<ion-icon name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
<!-- Karaoke Toggle (Moved down) -->
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'">
<ion-icon [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
</ion-button>
<!-- Youtube Link -->
<ion-button *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" (click)="openYoutube()">
<ion-icon name="logo-youtube" color="danger"></ion-icon>
</ion-button>
</div>
</div>
</div>
+8 -1
View File
@@ -15,7 +15,8 @@
// Force left alignment in Ionic toolbar
ion-title {
padding-inline: 8px;
padding-inline-start: 56px; // Clear the back button on iOS/Apple devices
padding-inline-end: 8px;
text-align: left !important;
}
@@ -553,3 +554,9 @@ ion-content.full-screen-content {
color: #c0392b !important;
border-color: #c0392b !important;
}
:host-context(.md) {
.top-toolbar ion-title {
padding-inline-start: 8px;
}
}
+83 -18
View File
@@ -69,7 +69,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
// Apply transposition if in chords mode
if (this.showChords() && this.transposeAmount() !== 0) {
if (this.showChords()) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount());
}
@@ -121,6 +121,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private songStartTime: number = 0;
constructor() {
// Sync transposition automatically to display/projection page
effect(() => {
const amount = this.transposeAmount();
this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount });
});
// Sync mode from global settings
effect(() => {
this.showChords.set(this.settingsService.showChordsDefault());
@@ -186,23 +192,52 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.songStartTime = Date.now();
this.cantiService.getStorage()?.set('last_song_id', id);
this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id });
// Set custom community transposition if active
if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
const settings = this.comunitaService.comunitaCantiSettings();
const songSetting = settings.find(s => s.id_canti === found.id_canti || s.id_canti === Number(found.id));
if (songSetting && songSetting.tonalita !== undefined) {
this.transposeAmount.set(songSetting.tonalita);
} else {
this.transposeAmount.set(0);
}
} else {
this.transposeAmount.set(0);
}
}
}
}
}, { allowSignalWrites: true });
// Reactive transposition and speed determination based on canto, playlists, and community settings
effect(() => {
const c = this.canto();
if (!c) return;
const activePlaylistId = this.playlistService.activePlaylistId();
let playlistSongSetting: any = null;
if (activePlaylistId) {
const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId);
if (pl && pl.songSettings && pl.songSettings[c.id]) {
playlistSongSetting = pl.songSettings[c.id];
}
}
if (playlistSongSetting) {
this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0);
this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2);
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
const settings = this.comunitaService.comunitaCantiSettings();
const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id));
if (songSetting) {
if (songSetting.tonalita !== undefined) {
this.transposeAmount.set(songSetting.tonalita);
} else {
this.transposeAmount.set(0);
}
if (songSetting.speed !== undefined && songSetting.speed > 0) {
const mappedSpeed = Math.max(1, Math.min(10, Math.round(songSetting.speed / 100)));
this.autoscrollSpeed.set(mappedSpeed);
} else {
this.autoscrollSpeed.set(2);
}
} else {
this.transposeAmount.set(0);
this.autoscrollSpeed.set(2);
}
} else {
this.transposeAmount.set(0);
this.autoscrollSpeed.set(2);
}
}, { allowSignalWrites: true });
}
ngAfterViewInit() {
@@ -304,21 +339,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
transposeUp() {
this.transposeAmount.update(v => v + 1);
this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount: this.transposeAmount() });
const c = this.canto();
if (c) {
this.statsService.updateSongSettings(c.id_canti, this.transposeAmount());
const dbSpeed = this.autoscrollSpeed() * 100;
this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed);
const activePlaylistId = this.playlistService.activePlaylistId();
if (activePlaylistId) {
this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed());
}
}
}
transposeDown() {
this.transposeAmount.update(v => v - 1);
this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount: this.transposeAmount() });
const c = this.canto();
if (c) {
this.statsService.updateSongSettings(c.id_canti, this.transposeAmount());
const dbSpeed = this.autoscrollSpeed() * 100;
this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed);
const activePlaylistId = this.playlistService.activePlaylistId();
if (activePlaylistId) {
this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed());
}
}
}
@@ -600,10 +645,30 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
increaseAutoscrollSpeed() {
this.autoscrollSpeed.update(s => Math.min(10, s + 1));
const c = this.canto();
if (c) {
const dbSpeed = this.autoscrollSpeed() * 100;
this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed);
const activePlaylistId = this.playlistService.activePlaylistId();
if (activePlaylistId) {
this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed());
}
}
}
decreaseAutoscrollSpeed() {
this.autoscrollSpeed.update(s => Math.max(1, s - 1));
const c = this.canto();
if (c) {
const dbSpeed = this.autoscrollSpeed() * 100;
this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed);
const activePlaylistId = this.playlistService.activePlaylistId();
if (activePlaylistId) {
this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed());
}
}
}
private logPreviousSongTime() {
+22 -3
View File
@@ -60,6 +60,15 @@ export class PlaylistPage {
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex);
}
getPlaylistSongSettings(): any {
const activeId = this.playlistService.activePlaylistId();
if (activeId) {
const pl = this.playlistService.playlists().find(p => p.id === activeId);
return pl ? pl.songSettings : undefined;
}
return undefined;
}
async savePlaylist() {
const alert = await this.alertCtrl.create({
header: 'Salva Playlist',
@@ -118,7 +127,8 @@ export class PlaylistPage {
this.savedPlaylistName = data.name;
const ids = this.localSongs.map(s => s.id);
await this.playlistService.savePlaylist(data.name, ids);
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name);
const songSettings = this.getPlaylistSongSettings();
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name, songSettings);
this.showToast('Playlist salvata!');
return true;
}
@@ -135,7 +145,8 @@ export class PlaylistPage {
const ids = this.localSongs.map(s => s.id);
const name = this.savedPlaylistName || 'Playlist Condivisa';
this.qrCodeImage = await this.playlistService.generateQR(ids, name);
const songSettings = this.getPlaylistSongSettings();
this.qrCodeImage = await this.playlistService.generateQR(ids, name, songSettings);
}
downloadQR() {
@@ -157,7 +168,8 @@ export class PlaylistPage {
async shareQR() {
if (!this.qrCodeImage) return;
const name = this.savedPlaylistName || 'playlist';
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name);
const songSettings = this.getPlaylistSongSettings();
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name, songSettings);
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try {
@@ -180,6 +192,13 @@ export class PlaylistPage {
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
});
} else {
// Copy to clipboard AND download QR!
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
this.showToast('Link copiato negli appunti! QR scaricato.');
}
} catch(e) {}
this.downloadQR();
}
}
+36 -3
View File
@@ -35,11 +35,11 @@
<ion-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
</ion-item>
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02);">
<p class="outfit-font" style="font-size: 0.85rem; color: rgba(255,255,255,0.8); margin: 0 0 12px 0; line-height: 1.4;">
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid var(--ion-border-color, rgba(255,255,255,0.08)); background: rgba(var(--ion-text-color-rgb, 255,255,255), 0.02);">
<p class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0 0 12px 0; line-height: 1.4; opacity: 0.9;">
Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
</p>
<ol class="outfit-font" style="font-size: 0.85rem; color: rgba(255,255,255,0.8); margin: 0; padding-left: 20px; line-height: 1.6;">
<ol class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0; padding-left: 20px; line-height: 1.6; opacity: 0.9;">
<li style="margin-bottom: 8px;">
Tocca il pulsante di <strong>Condivisione</strong> <ion-icon name="share-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon> nella barra di navigazione inferiore di Safari.
</li>
@@ -185,6 +185,30 @@
</div>
</div>
<!-- Chord Notation Preference Section -->
<div class="settings-group glass ion-margin-bottom">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title">
Notazione Accordi Preferita
</h2>
</div>
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
<div class="filter-btn glass"
[class.active-btn]="settingsService.chordNotationPreference() === 'diesis'"
(click)="settingsService.setChordNotationPreference('diesis')">
<span>Diesis (#)</span>
</div>
<div class="filter-btn glass"
[class.active-btn]="settingsService.chordNotationPreference() === 'bemolle'"
(click)="settingsService.setChordNotationPreference('bemolle')">
<span>Bemolle (b)</span>
</div>
</div>
</div>
</div>
@@ -207,6 +231,15 @@
</ion-label>
<ion-spinner slot="end" name="crescent" color="secondary" *ngIf="cantiService.loading()"></ion-spinner>
</ion-item>
<ion-item class="transparent-item" lines="none" (click)="checkForAppUpdate()" detail="true" button style="border-top: 1px solid rgba(255,255,255,0.06);">
<ion-icon name="sync-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Verifica Aggiornamenti App</h2>
<p class="settings-item-subtitle">Forza la ricerca di una nuova versione dell'applicazione</p>
</ion-label>
</ion-item>
<div class="sync-info ion-padding-bottom">
<p class="outfit-font settings-item-subtitle">
Versione: <strong>v{{ version }}</strong> &bull;
+85 -4
View File
@@ -3,11 +3,12 @@ import { ThemeService } from '../../services/theme.service';
import { SettingsService } from '../../services/settings.service';
import { CantiService } from '../../services/canti.service';
import { ConnectivityService } from '../../services/connectivity.service';
import { SwUpdate } from '@angular/service-worker';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { ToastController, ModalController, AlertController } from '@ionic/angular';
import { VERSION } from '../../version';
import { PlaylistService } from '../../services/playlist.service';
import { Router } from '@angular/router';
import { filter, first } from 'rxjs/operators';
import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service';
@@ -91,9 +92,23 @@ export class SettingsPage {
});
await toast.present();
setTimeout(() => {
window.location.reload();
}, 2000);
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(async () => {
console.log('[PWA-Update] FullRefresh: version ready, activating...');
await this.swUpdate.activateUpdate();
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
});
setTimeout(async () => {
try {
await this.swUpdate.activateUpdate();
} catch(e) {}
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 6000);
return;
}
} catch (err) {
@@ -108,4 +123,70 @@ export class SettingsPage {
});
await toast.present();
}
async checkForAppUpdate() {
if (!this.swUpdate.isEnabled) {
const toast = await this.toastCtrl.create({
message: 'Aggiornamenti non supportati su questo browser.',
duration: 3000,
color: 'medium'
});
await toast.present();
return;
}
const toastLoading = await this.toastCtrl.create({
message: 'Ricerca aggiornamenti in corso...',
duration: 1500,
color: 'secondary'
});
await toastLoading.present();
try {
const updateFound = await this.swUpdate.checkForUpdate();
if (updateFound) {
const toast = await this.toastCtrl.create({
message: 'Nuova versione trovata! Installazione e attivazione in corso...',
duration: 3000,
color: 'success'
});
await toast.present();
// Sottoscrizione per attivare l'aggiornamento appena terminato il download
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(async () => {
console.log('[PWA-Update] Manual check: version ready, activating...');
await this.swUpdate.activateUpdate();
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
});
// Timeout di sicurezza per forzare l'attivazione e il ricaricamento se è già scaricato
setTimeout(async () => {
try {
await this.swUpdate.activateUpdate();
} catch(e) {}
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}, 8000);
} else {
const toast = await this.toastCtrl.create({
message: 'L\'applicazione è già aggiornata all\'ultima versione.',
duration: 3000,
color: 'success'
});
await toast.present();
}
} catch (err) {
console.error('Check update failed', err);
const toast = await this.toastCtrl.create({
message: 'Errore durante la ricerca di aggiornamenti.',
duration: 3000,
color: 'danger'
});
await toast.present();
}
}
}