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
+21 -4
View File
@@ -2,6 +2,7 @@ import { Component, inject, ApplicationRef } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, first } from 'rxjs/operators'; import { filter, first } from 'rxjs/operators';
import { concat, interval, fromEvent } from 'rxjs'; import { concat, interval, fromEvent } from 'rxjs';
import { ToastController } from '@ionic/angular';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -12,6 +13,7 @@ import { concat, interval, fromEvent } from 'rxjs';
export class AppComponent { export class AppComponent {
private swUpdate = inject(SwUpdate); private swUpdate = inject(SwUpdate);
private appRef = inject(ApplicationRef); private appRef = inject(ApplicationRef);
private toastCtrl = inject(ToastController);
constructor() { constructor() {
this.setupUpdates(); this.setupUpdates();
@@ -55,14 +57,29 @@ export class AppComponent {
} }
}); });
// 4. Activate update and reload when a new version is ready // 4. Activate update and reload when a new version is ready (Show interactive toast)
this.swUpdate.versionUpdates this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY')) .pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
.subscribe(() => { .subscribe(async () => {
console.log('[PWA-Update] New version ready! Activating and reloading...'); console.log('[PWA-Update] New version ready! Showing toast prompt...');
const toast = await this.toastCtrl.create({
message: 'Nuova versione dell\'applicazione disponibile!',
position: 'bottom',
color: 'secondary',
buttons: [
{
text: 'Aggiorna',
role: 'cancel',
handler: () => {
console.log('[PWA-Update] Activating update and reloading...');
this.swUpdate.activateUpdate().then(() => { this.swUpdate.activateUpdate().then(() => {
window.location.reload(); window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
}); });
}
}
]
});
await toast.present();
}); });
} }
} }
@@ -11,11 +11,30 @@
<div class="scanner-container"> <div class="scanner-container">
<zxing-scanner <zxing-scanner
[formats]="allowedFormats" [formats]="allowedFormats"
[device]="currentDevice"
(camerasFound)="onCamerasFound($event)"
(scanSuccess)="onCodeResult($event)"> (scanSuccess)="onCodeResult($event)">
</zxing-scanner> </zxing-scanner>
<div class="scan-overlay"> <div class="scan-overlay">
<div class="scan-frame"></div> <div class="scan-frame"></div>
<p class="scan-text">Inquadra il QR Code della tua parrocchia</p> <p class="scan-text">Inquadra il QR Code della tua parrocchia</p>
</div> </div>
<!-- Pulsante premium per cambiare fotocamera -->
<div class="camera-toggle-container" *ngIf="availableDevices.length > 1">
<button (click)="toggleCamera()" class="camera-toggle-btn">
<ion-icon name="camera-reverse-outline"></ion-icon>
<span>Cambia fotocamera</span>
</button>
</div>
<!-- Messaggio di aiuto per utenti iPad in modalità desktop -->
<div class="ipad-warning-container" *ngIf="showIpadWarning">
<ion-icon name="information-circle-outline"></ion-icon>
<p>
Su iPad, se non vedi lo switch fotocamera, tocca l'icona <strong>"aA"</strong> in alto nella barra di Safari e seleziona <strong>"Richiedi sito mobile"</strong>.
</p>
</div>
</div> </div>
</ion-content> </ion-content>
@@ -55,6 +55,91 @@
padding: 0 40px; padding: 0 40px;
} }
} }
.camera-toggle-container {
position: absolute;
bottom: 50px;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.camera-toggle-btn {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.25);
color: white;
padding: 12px 24px;
border-radius: 30px;
font-family: 'Outfit', sans-serif;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
cursor: pointer;
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
outline: none;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
&:active {
background: rgba(255, 255, 255, 0.35);
transform: scale(0.96);
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.37);
}
ion-icon {
font-size: 20px;
}
}
.ipad-warning-container {
position: absolute;
bottom: 40px;
left: 24px;
right: 24px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 14px;
padding: 14px 18px;
display: flex;
align-items: flex-start;
gap: 12px;
color: white;
z-index: 10;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
ion-icon {
font-size: 24px;
color: var(--ion-color-secondary);
flex-shrink: 0;
margin-top: 2px;
}
p {
margin: 0;
font-family: 'Outfit', sans-serif;
font-size: 0.82rem;
line-height: 1.45;
font-weight: 500;
text-align: left;
strong {
color: var(--ion-color-secondary);
font-weight: 700;
}
}
}
} }
@keyframes scan { @keyframes scan {
@@ -16,6 +16,39 @@ export class QrScannerComponent {
private modalCtrl = inject(ModalController); private modalCtrl = inject(ModalController);
public allowedFormats = [BarcodeFormat.QR_CODE]; public allowedFormats = [BarcodeFormat.QR_CODE];
public availableDevices: MediaDeviceInfo[] = [];
public currentDevice: MediaDeviceInfo | undefined = undefined;
onCamerasFound(devices: MediaDeviceInfo[]) {
this.availableDevices = devices;
if (devices && devices.length > 0) {
// Cerca la fotocamera posteriore (etichette contenenti 'back', 'rear', 'environment', 'posteriore')
const backCamera = devices.find(d => {
const label = d.label.toLowerCase();
return label.includes('back') ||
label.includes('rear') ||
label.includes('environment') ||
label.includes('posteriore');
});
this.currentDevice = backCamera || devices[0];
}
}
toggleCamera() {
if (this.availableDevices.length <= 1) return;
const currentIndex = this.availableDevices.findIndex(d => d.deviceId === this.currentDevice?.deviceId);
const nextIndex = (currentIndex + 1) % this.availableDevices.length;
this.currentDevice = this.availableDevices[nextIndex];
}
get showIpadWarning(): boolean {
const isIPadDesktop =
/Macintosh/.test(navigator.userAgent) &&
navigator.maxTouchPoints !== undefined &&
navigator.maxTouchPoints > 1;
return isIPadDesktop && this.availableDevices.length <= 1;
}
onCodeResult(result: string) { onCodeResult(result: string) {
if (result) { if (result) {
this.modalCtrl.dismiss(result); this.modalCtrl.dismiss(result);
+1 -1
View File
@@ -160,7 +160,7 @@
<!-- Active Playlist Actions --> <!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()"> <div class="selection-pill glass" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist()"> <ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon> <ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn"> <ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
+2 -2
View File
@@ -174,7 +174,7 @@ ion-title {
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 12px; gap: 12px;
padding: 16px 0 4px 24px; // Reduced padding to bring search bar closer padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room
} }
.header-logo { .header-logo {
@@ -318,7 +318,7 @@ ion-title {
.search-wrapper-group { .search-wrapper-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 16px 8px 16px; padding: 6px 16px 8px 16px; // Added slight top padding for search bar breathing room
gap: 8px; gap: 8px;
} }
+80 -7
View File
@@ -131,9 +131,9 @@ export class HomePage implements OnDestroy {
...this.comunitaService.comunitaCantiPersonali() ...this.comunitaService.comunitaCantiPersonali()
]; ];
// Filter to only community canti // Filter to only community canti, but preserve canti that are in the active playlist
list = list.filter(c => { list = list.filter(c => {
return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id); return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id) || activeIds.includes(c.id);
}); });
// Deduplicate: parish-customized versions take precedence // Deduplicate: parish-customized versions take precedence
@@ -144,7 +144,7 @@ export class HomePage implements OnDestroy {
if (!existing) { if (!existing) {
seen.set(key, canto); seen.set(key, canto);
} else { } else {
if (!existing.nonValidato && canto.nonValidato) { if (!existing.isPersonal && canto.isPersonal) {
seen.set(key, canto); seen.set(key, canto);
} }
} }
@@ -253,6 +253,9 @@ export class HomePage implements OnDestroy {
}); });
public visibleCanti = computed(() => { public visibleCanti = computed(() => {
if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
return this.filteredCanti();
}
return this.filteredCanti().slice(0, this.limit()); return this.filteredCanti().slice(0, this.limit());
}); });
@@ -393,15 +396,70 @@ export class HomePage implements OnDestroy {
} }
} }
handleImport(base64: string) { async handleImport(base64: string) {
try { try {
const decoded = decodeURIComponent(escape(atob(base64))); const decoded = decodeURIComponent(escape(atob(base64)));
const json = JSON.parse(decoded); const json = JSON.parse(decoded);
if (json && json.comunitaCode) {
// 1. Abilita la funzionalità comunità nelle impostazioni
this.settingsService.comunitaEnabled.set(true);
localStorage.setItem('comunita-enabled', 'true');
// 2. Mostra un overlay di caricamento premium con indicatore di progresso
const loading = await this.loadingCtrl.create({
message: 'Attivazione comunità: Caricamento 0%',
cssClass: 'premium-loading',
spinner: 'crescent'
});
await loading.present();
// Iscrizione agli aggiornamenti di progresso
let progressInterval: any = null;
progressInterval = setInterval(() => {
const pct = this.comunitaService.loadingProgress();
loading.message = `Attivazione comunità: Caricamento ${pct}%`;
if (pct >= 100) {
clearInterval(progressInterval);
}
}, 100);
const success = await this.comunitaService.setComunitaCode(json.comunitaCode);
clearInterval(progressInterval);
await loading.dismiss();
if (success) {
const toast = await this.toastCtrl.create({
message: `Comunità attivata: ${this.comunitaService.comunitaNome()}`,
duration: 2500,
color: 'success',
position: 'bottom'
});
await toast.present();
} else {
const toast = await this.toastCtrl.create({
message: 'Codice comunità non trovato o errore di connessione.',
duration: 3000,
color: 'danger',
position: 'bottom'
});
await toast.present();
}
}
if (this.playlistService.processImportJson(json)) { if (this.playlistService.processImportJson(json)) {
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge' }); this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge' });
} }
} catch (e) { } catch (e) {
console.error('Failed to import playlist', e); console.error('Failed to import playlist', e);
const toast = await this.toastCtrl.create({
message: 'Errore durante l\'importazione della playlist.',
duration: 3000,
color: 'danger',
position: 'bottom'
});
await toast.present();
} }
} }
@@ -847,21 +905,25 @@ export class HomePage implements OnDestroy {
if (data.startsWith('canti:')) { if (data.startsWith('canti:')) {
const parts = data.replace('canti:', '').split(':'); const parts = data.replace('canti:', '').split(':');
let idsStr = ''; let idsStr = '';
let playlistName = 'Lista Parrocchiale';
if (parts.length > 1) { if (parts.length > 1) {
this.playlistService.activeListName.set(parts[0]); playlistName = parts[0];
idsStr = parts[1]; idsStr = parts[1];
} else { } else {
this.playlistService.activeListName.set('Lista Parrocchiale');
idsStr = parts[0]; idsStr = parts[0];
} }
const ids = idsStr.split(',').map(id => id.trim()); const ids = idsStr.split(',').map(id => id.trim());
this.playlistService.activeListIds.set(ids); this.playlistService.activeListIds.set(ids);
this.playlistService.activeListName.set(playlistName);
this.limit.set(50); // Show more initially for special lists this.limit.set(50); // Show more initially for special lists
// Clear other filters to avoid confusion // Clear other filters to avoid confusion
this.selectedLiturgico.set(null); this.selectedLiturgico.set(null);
this.selectedTematico.set(null); this.selectedTematico.set(null);
// Automatically save to the device!
this.playlistService.savePlaylist(playlistName, ids);
} }
} }
@@ -936,6 +998,12 @@ export class HomePage implements OnDestroy {
return !!id && id.startsWith('comunita_'); return !!id && id.startsWith('comunita_');
} }
isActivePlaylistSaved(): boolean {
const id = this.playlistService.activePlaylistId();
if (!id) return false;
return this.playlistService.playlists().some(p => p.id === id);
}
clearSpecialList(event?: Event) { clearSpecialList(event?: Event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
this.playlistService.activeListIds.set([]); this.playlistService.activeListIds.set([]);
@@ -955,7 +1023,12 @@ export class HomePage implements OnDestroy {
shareActivePlaylist() { shareActivePlaylist() {
const ids = this.playlistService.activeListIds(); const ids = this.playlistService.activeListIds();
const name = this.playlistService.activeListName() || 'Playlist'; const name = this.playlistService.activeListName() || 'Playlist';
this.playlistService.sharePlaylistQR(ids, name);
const id = this.playlistService.activePlaylistId();
const pl = this.playlistService.playlists().find(p => p.id === id);
const songSettings = pl ? pl.songSettings : undefined;
this.playlistService.sharePlaylistQR(ids, name, songSettings);
} }
async importPlaylist() { async importPlaylist() {
+35 -3
View File
@@ -16,14 +16,42 @@ export class DisplayPage implements OnInit, OnDestroy {
public showChords = signal<boolean>(false); public showChords = signal<boolean>(false);
public fontSize = signal<number>(1.0); public fontSize = signal<number>(1.0);
public currentLineIndex = signal<number>(0); public currentLineIndex = signal<number>(0);
public transposeAmount = signal<number>(0);
public parsedSections = computed<ParsedSection[]>(() => { public parsedSections = computed<ParsedSection[]>(() => {
const c = this.canto(); const c = this.canto();
if (!c) return []; 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 */ /** 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') { if (event.data.type === 'SYNC_CANTO') {
this.activeSongId.set(event.data.id); this.activeSongId.set(event.data.id);
this.transposeAmount.set(0);
} }
if (event.data.type === 'SYNC_CHORDS') { if (event.data.type === 'SYNC_CHORDS') {
this.showChords.set(event.data.showChords); this.showChords.set(event.data.showChords);
@@ -101,6 +130,9 @@ export class DisplayPage implements OnInit, OnDestroy {
if (event.data.type === 'SYNC_FONT') { if (event.data.type === 'SYNC_FONT') {
this.fontSize.set(event.data.fontSize); 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-header [translucent]="true" class="ion-no-border">
<ion-toolbar class="bg-gradient top-toolbar"> <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"> <ion-buttons slot="end">
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()"> <div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon> <ion-icon name="cloud-offline-outline"></ion-icon>
@@ -12,21 +26,6 @@
</ion-icon> </ion-icon>
</ion-button> </ion-button>
</ion-buttons> </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> </ion-toolbar>
<!-- Audio Toolbar Removed --> <!-- Audio Toolbar Removed -->
@@ -44,50 +43,19 @@
<!-- Landscape Side Controls (Scrollable) --> <!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()"> <div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container"> <div class="side-scroll-container">
<!-- Autoscroll group in Landscape Side --> <!-- Karaoke Line Advancer (Avanzatore di riga) -->
<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;"> <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" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10" style="height: 32px; margin: 0;"> <ion-button fill="clear" (click)="restart()" style="height: 48px; margin: 0;">
<ion-icon name="add" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon> <ion-icon name="arrow-up-circle" color="secondary" style="font-size: 1.8rem;"></ion-icon>
</ion-button> </ion-button>
<div (click)="toggleAutoscroll()" style="cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px;"> <ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0" style="height: 48px; margin: 0;">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'" style="font-size: 1.4rem;"></ion-icon> <ion-icon name="chevron-up" color="secondary" style="font-size: 1.8rem;"></ion-icon>
<span style="font-size: 0.65rem; font-weight: 700; color: var(--ion-color-secondary);">V{{ autoscrollSpeed() }}</span> </ion-button>
</div> <ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1" style="height: 48px; margin: 0;">
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1" style="height: 32px; margin: 0;"> <ion-icon name="chevron-down" color="secondary" style="font-size: 1.8rem;"></ion-icon>
<ion-icon name="remove" style="font-size: 1.2rem; color: var(--ion-color-secondary);"></ion-icon>
</ion-button> </ion-button>
</div> </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>
</div> </div>
+8 -1
View File
@@ -15,7 +15,8 @@
// Force left alignment in Ionic toolbar // Force left alignment in Ionic toolbar
ion-title { 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; text-align: left !important;
} }
@@ -553,3 +554,9 @@ ion-content.full-screen-content {
color: #c0392b !important; color: #c0392b !important;
border-color: #c0392b !important; border-color: #c0392b !important;
} }
:host-context(.md) {
.top-toolbar ion-title {
padding-inline-start: 8px;
}
}
+76 -11
View File
@@ -69,7 +69,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
// Apply transposition if in chords mode // Apply transposition if in chords mode
if (this.showChords() && this.transposeAmount() !== 0) { if (this.showChords()) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount()); return this.lyricsParser.transposeSections(sections, this.transposeAmount());
} }
@@ -121,6 +121,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private songStartTime: number = 0; private songStartTime: number = 0;
constructor() { 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 // Sync mode from global settings
effect(() => { effect(() => {
this.showChords.set(this.settingsService.showChordsDefault()); this.showChords.set(this.settingsService.showChordsDefault());
@@ -186,21 +192,50 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.songStartTime = Date.now(); this.songStartTime = Date.now();
this.cantiService.getStorage()?.set('last_song_id', id); this.cantiService.getStorage()?.set('last_song_id', id);
this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id }); this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id });
}
}
}
}, { allowSignalWrites: true });
// Set custom community transposition if active // Reactive transposition and speed determination based on canto, playlists, and community settings
if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) { 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 settings = this.comunitaService.comunitaCantiSettings();
const songSetting = settings.find(s => s.id_canti === found.id_canti || s.id_canti === Number(found.id)); const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id));
if (songSetting && songSetting.tonalita !== undefined) { if (songSetting) {
if (songSetting.tonalita !== undefined) {
this.transposeAmount.set(songSetting.tonalita); this.transposeAmount.set(songSetting.tonalita);
} else { } else {
this.transposeAmount.set(0); 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 { } else {
this.transposeAmount.set(0); this.transposeAmount.set(0);
this.autoscrollSpeed.set(2);
} }
} } else {
} this.transposeAmount.set(0);
this.autoscrollSpeed.set(2);
} }
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
} }
@@ -304,21 +339,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
transposeUp() { transposeUp() {
this.transposeAmount.update(v => v + 1); this.transposeAmount.update(v => v + 1);
this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount: this.transposeAmount() });
const c = this.canto(); const c = this.canto();
if (c) { 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() { transposeDown() {
this.transposeAmount.update(v => v - 1); this.transposeAmount.update(v => v - 1);
this.channel.postMessage({ type: 'SYNC_TRANSPOSE', amount: this.transposeAmount() });
const c = this.canto(); const c = this.canto();
if (c) { 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() { increaseAutoscrollSpeed() {
this.autoscrollSpeed.update(s => Math.min(10, s + 1)); 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() { decreaseAutoscrollSpeed() {
this.autoscrollSpeed.update(s => Math.max(1, s - 1)); 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() { private logPreviousSongTime() {
+22 -3
View File
@@ -60,6 +60,15 @@ export class PlaylistPage {
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex); 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() { async savePlaylist() {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Salva Playlist', header: 'Salva Playlist',
@@ -118,7 +127,8 @@ export class PlaylistPage {
this.savedPlaylistName = data.name; this.savedPlaylistName = data.name;
const ids = this.localSongs.map(s => s.id); const ids = this.localSongs.map(s => s.id);
await this.playlistService.savePlaylist(data.name, ids); 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!'); this.showToast('Playlist salvata!');
return true; return true;
} }
@@ -135,7 +145,8 @@ export class PlaylistPage {
const ids = this.localSongs.map(s => s.id); const ids = this.localSongs.map(s => s.id);
const name = this.savedPlaylistName || 'Playlist Condivisa'; 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() { downloadQR() {
@@ -157,7 +168,8 @@ export class PlaylistPage {
async shareQR() { async shareQR() {
if (!this.qrCodeImage) return; if (!this.qrCodeImage) return;
const name = this.savedPlaylistName || 'playlist'; 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`; const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try { try {
@@ -180,6 +192,13 @@ export class PlaylistPage {
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}` text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
}); });
} else { } 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(); 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-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
</ion-item> </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);"> <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: rgba(255,255,255,0.8); margin: 0 0 12px 0; line-height: 1.4;"> <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>: Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
</p> </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;"> <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. 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> </li>
@@ -185,6 +185,30 @@
</div> </div>
</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-label>
<ion-spinner slot="end" name="crescent" color="secondary" *ngIf="cantiService.loading()"></ion-spinner> <ion-spinner slot="end" name="crescent" color="secondary" *ngIf="cantiService.loading()"></ion-spinner>
</ion-item> </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"> <div class="sync-info ion-padding-bottom">
<p class="outfit-font settings-item-subtitle"> <p class="outfit-font settings-item-subtitle">
Versione: <strong>v{{ version }}</strong> &bull; 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 { SettingsService } from '../../services/settings.service';
import { CantiService } from '../../services/canti.service'; import { CantiService } from '../../services/canti.service';
import { ConnectivityService } from '../../services/connectivity.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 { ToastController, ModalController, AlertController } from '@ionic/angular';
import { VERSION } from '../../version'; import { VERSION } from '../../version';
import { PlaylistService } from '../../services/playlist.service'; import { PlaylistService } from '../../services/playlist.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { filter, first } from 'rxjs/operators';
import { MyCantiService } from '../../services/my-canti.service'; import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service'; import { CantiLettureService } from '../../services/canti-letture.service';
@@ -91,9 +92,23 @@ export class SettingsPage {
}); });
await toast.present(); await toast.present();
setTimeout(() => { this.swUpdate.versionUpdates
window.location.reload(); .pipe(
}, 2000); 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; return;
} }
} catch (err) { } catch (err) {
@@ -108,4 +123,70 @@ export class SettingsPage {
}); });
await toast.present(); 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();
}
}
} }
+1
View File
@@ -13,6 +13,7 @@ export interface Canto {
id_momenti?: number[]; id_momenti?: number[];
data_update?: string; data_update?: string;
nonValidato?: boolean; nonValidato?: boolean;
isPersonal?: boolean;
} }
export interface Indice { export interface Indice {
+2 -1
View File
@@ -186,7 +186,8 @@ export class ComunitaService {
link_youtube: cp.link_youtube || '', link_youtube: cp.link_youtube || '',
id_momenti: [], id_momenti: [],
data_update: cp.data_update || '', data_update: cp.data_update || '',
nonValidato: true nonValidato: Number(cp.stato) === 10,
isPersonal: true
})); }));
this.comunitaCode.set(trimmedCode); this.comunitaCode.set(trimmedCode);
+24 -7
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { SettingsService } from './settings.service';
export interface ChordSegment { export interface ChordSegment {
text: string; text: string;
@@ -20,6 +21,7 @@ export interface ParsedSection {
providedIn: 'root' providedIn: 'root'
}) })
export class LyricsParserService { export class LyricsParserService {
private settingsService = inject(SettingsService);
/** /**
* Parse plain text (campo 'testo') into structured sections. * Parse plain text (campo 'testo') into structured sections.
@@ -180,7 +182,7 @@ export class LyricsParserService {
* Handles Italian notation. * Handles Italian notation.
*/ */
transposeChord(chord: string, semitones: number): string { transposeChord(chord: string, semitones: number): string {
if (!chord || semitones === 0) return chord; if (!chord) return chord;
// Handle slash chords (e.g., DO/SOL) // Handle slash chords (e.g., DO/SOL)
if (chord.includes('/')) { if (chord.includes('/')) {
@@ -193,8 +195,9 @@ export class LyricsParserService {
let root = ''; let root = '';
let suffix = ''; let suffix = '';
const upperChord = chord.toUpperCase();
for (const r of possibleRoots) { for (const r of possibleRoots) {
if (chord.startsWith(r)) { if (upperChord.startsWith(r)) {
root = r; root = r;
suffix = chord.substring(r.length); suffix = chord.substring(r.length);
break; break;
@@ -210,8 +213,24 @@ export class LyricsParserService {
let newIndex = (index + semitones) % 12; let newIndex = (index + semitones) % 12;
if (newIndex < 0) newIndex += 12; if (newIndex < 0) newIndex += 12;
// Preserve the original notation style (sharp or flat) if possible // Decide flat vs sharp notation based on SettingsService preference:
const useFlat = this.flatScale.includes(root); const pref = this.settingsService.chordNotationPreference();
let useFlat = false;
if (pref === 'diesis') {
useFlat = false;
} else if (pref === 'bemolle') {
useFlat = true;
} else {
// Fallback/Default logic
if (root.includes('#')) {
useFlat = false;
} else if (root.toLowerCase().includes('b')) {
useFlat = true;
} else {
useFlat = semitones < 0;
}
}
const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex]; const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
return newRoot + suffix; return newRoot + suffix;
@@ -221,8 +240,6 @@ export class LyricsParserService {
* Transpose all chords in a parsed structure. * Transpose all chords in a parsed structure.
*/ */
transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] { transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] {
if (semitones === 0) return sections;
return sections.map(section => ({ return sections.map(section => ({
...section, ...section,
lines: section.lines.map(line => ({ lines: section.lines.map(line => ({
+99 -11
View File
@@ -3,6 +3,7 @@ import { Storage } from '@ionic/storage-angular';
import { Canto, CantiService } from './canti.service'; import { Canto, CantiService } from './canti.service';
import { ComunitaService } from './comunita.service'; import { ComunitaService } from './comunita.service';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { ToastController } from '@ionic/angular';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -11,6 +12,7 @@ export class PlaylistService {
private storage = inject(Storage); private storage = inject(Storage);
private cantiService = inject(CantiService); private cantiService = inject(CantiService);
private comunitaService = inject(ComunitaService); private comunitaService = inject(ComunitaService);
private toastCtrl = inject(ToastController);
public selectionMode = signal<boolean>(false); public selectionMode = signal<boolean>(false);
public selectedIds = signal<Set<string>>(new Set()); public selectedIds = signal<Set<string>>(new Set());
@@ -23,6 +25,7 @@ export class PlaylistService {
public activePlaylistId = signal<string | null>(null); public activePlaylistId = signal<string | null>(null);
private _storage: Storage | null = null; private _storage: Storage | null = null;
private initPromise!: Promise<void>;
// Community scalette exposed as playlists (only when community filter is active) // Community scalette exposed as playlists (only when community filter is active)
public comunitaPlaylists = computed(() => { public comunitaPlaylists = computed(() => {
@@ -49,7 +52,7 @@ export class PlaylistService {
}); });
constructor() { constructor() {
this.init(); this.initPromise = this.init();
// Watch for context changes to dynamically reload the correct playlists // Watch for context changes to dynamically reload the correct playlists
effect(() => { effect(() => {
@@ -114,7 +117,8 @@ export class PlaylistService {
}); });
} }
async savePlaylist(name: string, ids: string[]) { async savePlaylist(name: string, ids: string[], songSettings?: any) {
await this.initPromise;
const key = this.getPlaylistsStorageKey(); const key = this.getPlaylistsStorageKey();
const lastKey = `lastPlaylist_${key}`; const lastKey = `lastPlaylist_${key}`;
const editId = this.activePlaylistId(); const editId = this.activePlaylistId();
@@ -123,7 +127,8 @@ export class PlaylistService {
if (editId) { if (editId) {
this.playlists.update(p => p.map(pl => { this.playlists.update(p => p.map(pl => {
if (pl.id === editId) { if (pl.id === editId) {
return { ...pl, name, ids }; const mergedSettings = songSettings || pl.songSettings || {};
return { ...pl, name, ids, songSettings: mergedSettings };
} }
return pl; return pl;
})); }));
@@ -133,6 +138,7 @@ export class PlaylistService {
id: Date.now().toString(), id: Date.now().toString(),
name, name,
ids, ids,
songSettings: songSettings || {},
createdAt: new Date() createdAt: new Date()
}; };
this.playlists.update(p => [newPlaylist, ...p]); this.playlists.update(p => [newPlaylist, ...p]);
@@ -150,14 +156,39 @@ export class PlaylistService {
this.activePlaylistId.set(newPlaylist.id); this.activePlaylistId.set(newPlaylist.id);
} }
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number) {
await this.initPromise;
this.playlists.update(p => p.map(pl => {
if (pl.id === playlistId) {
const songSettings = { ...(pl.songSettings || {}) };
songSettings[songId] = { tonalita, speed };
return { ...pl, songSettings };
}
return pl;
}));
const key = this.getPlaylistsStorageKey();
await this._storage?.set(key, this.playlists());
// Also update lastPlaylist if it is the current one
const lastKey = `lastPlaylist_${key}`;
const last = this.lastPlaylist();
if (last && last.id === playlistId) {
const updatedLast = this.playlists().find(pl => pl.id === playlistId);
this.lastPlaylist.set(updatedLast || null);
await this._storage?.set(lastKey, updatedLast);
}
}
async deletePlaylist(id: string) { async deletePlaylist(id: string) {
await this.initPromise;
const key = this.getPlaylistsStorageKey(); const key = this.getPlaylistsStorageKey();
this.playlists.update(p => p.filter(pl => pl.id !== id)); this.playlists.update(p => p.filter(pl => pl.id !== id));
await this._storage?.set(key, this.playlists()); await this._storage?.set(key, this.playlists());
} }
async generateQR(ids: string[], name: string): Promise<string> { async generateQR(ids: string[], name: string, songSettings?: any): Promise<string> {
const data = this.getShareLink(ids, name); const data = this.getShareLink(ids, name, songSettings);
return await QRCode.toDataURL(data, { return await QRCode.toDataURL(data, {
width: 400, width: 400,
margin: 2, margin: 2,
@@ -168,8 +199,48 @@ export class PlaylistService {
}); });
} }
getShareLink(ids: string[], name: string): string { getShareLink(ids: string[], name: string, songSettings?: any): string {
const data = JSON.stringify({ name, ids }); const mergedSettings = { ...(songSettings || {}) };
const cc = this.comunitaService.comunitaCode();
const isCommunityActive = this.comunitaService.isFilterActive();
if (cc && isCommunityActive) {
const communitySettings = this.comunitaService.comunitaCantiSettings();
for (const id of ids) {
if (mergedSettings[id] === undefined) {
const cSettings = communitySettings.find(s =>
s.id_canti === Number(id) || String(s.id_canti) === id
);
if (cSettings) {
const tonalita = cSettings.tonalita !== undefined ? cSettings.tonalita : 0;
let speed = 2;
if (cSettings.speed !== undefined && cSettings.speed > 0) {
speed = Math.max(1, Math.min(10, Math.round(cSettings.speed / 100)));
}
mergedSettings[id] = { tonalita, speed };
}
}
}
}
const shareObj: any = { name, ids, songSettings: mergedSettings };
if (cc && isCommunityActive) {
const communityCantiIds = this.comunitaService.comunitaCantiIds();
const communityCantiPersonali = this.comunitaService.comunitaCantiPersonali();
const containsCommunitySong = ids.some(id => {
const isStandard = communityCantiIds.includes(id) || communityCantiIds.includes(Number(id));
const isPersonal = communityCantiPersonali.some(cp => cp.id === id);
return isStandard || isPersonal;
});
if (containsCommunitySong) {
shareObj.comunitaCode = cc;
}
}
const data = JSON.stringify(shareObj);
// Use btoa safely for UTF-8 strings // Use btoa safely for UTF-8 strings
const base64 = btoa(unescape(encodeURIComponent(data))); const base64 = btoa(unescape(encodeURIComponent(data)));
@@ -180,16 +251,19 @@ export class PlaylistService {
processImportJson(json: any): boolean { processImportJson(json: any): boolean {
if (json && json.name && json.ids) { if (json && json.name && json.ids) {
this.activePlaylistId.set(null); // Forza il salvataggio come nuova playlist indipendente ed editabile
this.activeListIds.set(json.ids); this.activeListIds.set(json.ids);
this.activeListName.set(json.name); this.activeListName.set(json.name);
// Automatically save to the device!
this.savePlaylist(json.name, json.ids, json.songSettings);
return true; return true;
} }
return false; return false;
} }
async sharePlaylistQR(ids: string[], name: string) { async sharePlaylistQR(ids: string[], name: string, songSettings?: any) {
const qrImage = await this.generateQR(ids, name); const qrImage = await this.generateQR(ids, name, songSettings);
const shareLink = this.getShareLink(ids, name); const shareLink = this.getShareLink(ids, name, songSettings);
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`; const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try { try {
@@ -204,7 +278,21 @@ export class PlaylistService {
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}` text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
}); });
} else { } else {
// Fallback: download // Fallback: copia il link negli appunti e scarica l'immagine del QR
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
const toast = await this.toastCtrl.create({
message: 'Link playlist copiato negli appunti! QR Code scaricato.',
duration: 3000,
color: 'success'
});
await toast.present();
}
} catch (clipErr) {
console.warn('Failed to copy link to clipboard:', clipErr);
}
const link = document.createElement('a'); const link = document.createElement('a');
link.href = qrImage; link.href = qrImage;
link.download = fileName; link.download = fileName;
+19
View File
@@ -47,6 +47,9 @@ export class SettingsService {
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */ /** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
public enableAcousticAutoscroll = signal<boolean>(false); public enableAcousticAutoscroll = signal<boolean>(false);
/** Preferenza notazione accordi: diesis o bemolle */
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
private wakeLock: any = null; private wakeLock: any = null;
// PWA installation signals // PWA installation signals
@@ -103,6 +106,7 @@ export class SettingsService {
localStorage.setItem('show-update-date', 'true'); localStorage.setItem('show-update-date', 'true');
localStorage.setItem('enable-standard-autoscroll', 'true'); localStorage.setItem('enable-standard-autoscroll', 'true');
localStorage.setItem('enable-acoustic-autoscroll', 'false'); localStorage.setItem('enable-acoustic-autoscroll', 'false');
localStorage.setItem('chord-notation-preference', 'diesis');
// ThemeService high contrast default // ThemeService high contrast default
localStorage.setItem('high-contrast', 'true'); localStorage.setItem('high-contrast', 'true');
@@ -187,6 +191,13 @@ export class SettingsService {
this.enableAcousticAutoscroll.set(false); this.enableAcousticAutoscroll.set(false);
} }
const savedNotation = localStorage.getItem('chord-notation-preference');
if (savedNotation !== null) {
this.chordNotationPreference.set(savedNotation === 'bemolle' ? 'bemolle' : 'diesis');
} else {
this.chordNotationPreference.set('diesis');
}
// Sync browser fullscreen state with listeners (supporting vendor prefixes) // Sync browser fullscreen state with listeners (supporting vendor prefixes)
const updateFullscreenState = () => { const updateFullscreenState = () => {
const isFs = !!( const isFs = !!(
@@ -255,6 +266,10 @@ export class SettingsService {
localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString()); localStorage.setItem('enable-acoustic-autoscroll', this.enableAcousticAutoscroll().toString());
}); });
effect(() => {
localStorage.setItem('chord-notation-preference', this.chordNotationPreference());
});
effect(() => { effect(() => {
const active = this.keepScreenOn(); const active = this.keepScreenOn();
localStorage.setItem('keep-screen-on', active.toString()); localStorage.setItem('keep-screen-on', active.toString());
@@ -381,6 +396,10 @@ export class SettingsService {
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString()); localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
} }
setChordNotationPreference(val: 'diesis' | 'bemolle') {
this.chordNotationPreference.set(val);
}
async installPwa() { async installPwa() {
const promptEvent = this.deferredPrompt(); const promptEvent = this.deferredPrompt();
if (!promptEvent) { if (!promptEvent) {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.05.22.0110'; export const VERSION = '2026.05.23.1602';