gestione comunità

This commit is contained in:
David Frassi
2026-05-18 16:07:37 +02:00
parent 961b7ba65c
commit af12a1f2da
15 changed files with 1021 additions and 61 deletions
+36 -3
View File
@@ -43,6 +43,27 @@
{{ filteredCanti().length }}
</div>
<div class="filter-buttons">
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
<ion-button
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
size="small"
(click)="toggleComunitaFilter()"
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
class="filter-chip"
style="margin: 0;">
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
</ion-button>
<div
*ngIf="comunitaService.comunitaCode()"
(click)="editComunitaCode($event)"
style="position: absolute; top: -6px; right: -6px; z-index: 99999; background: var(--ion-color-secondary); color: white; border-radius: 50%; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; border: 1.5px solid #1a1a1a; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"
class="floating-edit-badge">
<ion-icon name="create-outline" style="font-size: 0.85rem; pointer-events: none;"></ion-icon>
</div>
</div>
<ion-button
*ngIf="settingsService.showEditor()"
[fill]="showOnlyMine() ? 'solid' : 'outline'"
@@ -183,10 +204,12 @@
(cdkDropListDropped)="drop($event)"
class="transparent-list">
<!-- Premium Suggested Mass Card (Collapsible) -->
<!-- Premium Suggested Mass Card (Collapsible with Swipe support) -->
<div class="suggeriti-header-card glass ion-margin-bottom"
*ngIf="showSuggeriti() && cantiLettureService.data()"
(click)="isMassCardExpanded.set(!isMassCardExpanded())"
(touchstart)="onMassCardTouchStart($event)"
(touchend)="onMassCardTouchEnd($event)"
[class.expanded]="isMassCardExpanded()">
<div class="card-inner ion-padding">
<div class="card-header-row">
@@ -194,7 +217,17 @@
<span class="card-label">SINTESI MESSA</span>
<span class="card-date-compact" *ngIf="!isMassCardExpanded()">{{ getSelectedMassFormattedDate() }}</span>
</div>
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon"></ion-icon>
<div class="card-header-right" style="display: flex; align-items: center; gap: 8px;">
<div class="mass-nav-buttons" style="display: flex; align-items: center; gap: 4px; z-index: 10;" (click)="$event.stopPropagation()">
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(-1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
<ion-icon slot="icon-only" name="chevron-back-outline" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
<ion-icon slot="icon-only" name="chevron-forward-outline" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
</div>
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon" style="margin-left: 4px;"></ion-icon>
</div>
</div>
<div class="expandable-content" [class.show]="isMassCardExpanded()">
@@ -234,7 +267,7 @@
<div class="top-row">
<ion-label class="info-section">
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary)">
{{ canto.titolo }}
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span>{{ canto.titolo }}
</h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
+1 -1
View File
@@ -66,7 +66,7 @@
width: 100%;
overflow-x: auto;
gap: 12px;
padding: 4px 0;
padding: 8px 4px 8px 0;
// Hide scrollbar but keep functionality
&::-webkit-scrollbar {
+229 -9
View File
@@ -9,11 +9,12 @@ import { PlaylistService } from '../services/playlist.service';
import { SettingsService } from '../services/settings.service';
import { VERSION } from '../version';
import { YoutubePlayerService } from '../services/youtube-player.service';
import { ModalController, AlertController, ToastController } from '@ionic/angular';
import { ModalController, AlertController, ToastController, LoadingController } from '@ionic/angular';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { MyCantiService } from '../services/my-canti.service';
import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component';
import { CantiLettureService } from '../services/canti-letture.service';
import { ComunitaService } from '../services/comunita.service';
@Component({
selector: 'app-home',
@@ -38,6 +39,7 @@ export class HomePage implements OnDestroy {
public fontSize = signal<number>(1.0);
public youtubePlayerService = inject(YoutubePlayerService);
public comunitaService = inject(ComunitaService);
public isSeeking = false;
private readonly MIN_FONT = 0.6;
@@ -59,6 +61,7 @@ export class HomePage implements OnDestroy {
private modalCtrl = inject(ModalController);
private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController);
private loadingCtrl = inject(LoadingController);
private firstInteraction = true;
@@ -70,7 +73,14 @@ export class HomePage implements OnDestroy {
private scrollEffect = effect(() => {
const activeId = this.youtubePlayerService.currentCantoId();
const list = this.filteredCanti();
if (activeId) {
const index = list.findIndex(c => c.id === activeId);
if (index !== -1 && index >= this.limit()) {
this.limit.set(index + 15);
}
// Delay slightly to ensure DOM is updated and classes are applied
setTimeout(() => {
const element = document.getElementById('canto-' + activeId);
@@ -79,7 +89,7 @@ export class HomePage implements OnDestroy {
}
}, 500);
}
});
}, { allowSignalWrites: true });
private normalize(str: string | undefined): string {
if (!str) return '';
@@ -103,6 +113,25 @@ export class HomePage implements OnDestroy {
let list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
// Filter by Community if code is set and filter is active
const comunitaCode = this.comunitaService.comunitaCode();
const comunitaIds = this.comunitaService.comunitaCantiIds();
if (comunitaCode && this.comunitaService.isFilterActive()) {
list = list.filter(c => {
return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id);
});
// Sort list by community song number (num_canto) ASC!
const cantiInfo = this.comunitaService.comunitaCantiInfo();
list.sort((a, b) => {
const infoA = cantiInfo.find(x => x.id_canti === a.id_canti || x.id_canti === Number(a.id));
const infoB = cantiInfo.find(x => x.id_canti === b.id_canti || x.id_canti === Number(b.id));
const numA = infoA ? Number(infoA.num_canto) : 999999;
const numB = infoB ? Number(infoB.num_canto) : 999999;
return numA - numB;
});
}
if (onlyMine) {
list = this.myCantiService.myCanti();
}
@@ -160,17 +189,21 @@ export class HomePage implements OnDestroy {
// Converte "uno" in "1" per facilitare la ricerca vocale (es. "numero uno")
const processedQuery = query.replace(/\buno\b/g, '1');
// Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti
// Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti/comunita number
const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
if (numberMatchPattern) {
const targetId = numberMatchPattern[1];
return list.filter(c => c.id_canti.toString() === targetId);
return list.filter(c => {
const commNum = this.getCommunitySongNumber(c);
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
});
}
return list.filter(c => {
const titleMatch = this.normalize(c.titolo).includes(query);
const authorMatch = this.normalize(c.autore).includes(query);
const numberMatch = c.id_canti.toString().includes(query);
const commNum = this.getCommunitySongNumber(c);
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
// Strip tags like {Chorus} and newlines from lyrics before searching
const lyricsPlain = (c.testo || '')
@@ -642,10 +675,6 @@ export class HomePage implements OnDestroy {
setTimeout(() => {
this.limit.update(l => l + 10);
event.target.complete();
if (this.limit() >= this.filteredCanti().length) {
event.target.disabled = true;
}
}, 500);
}
@@ -883,4 +912,195 @@ export class HomePage implements OnDestroy {
this.limit.set(10);
}
getCommunitySongNumber(canto: any): string | null {
if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null;
const cantiInfo = this.comunitaService.comunitaCantiInfo();
const info = cantiInfo.find(x => x.id_canti === canto.id_canti || x.id_canti === Number(canto.id));
return info && info.num_canto ? info.num_canto.toString() : null;
}
toggleComunitaFilter() {
if (!this.comunitaService.comunitaCode()) {
this.promptComunitaCode();
} else {
this.comunitaService.isFilterActive.update(v => !v);
}
}
editComunitaCode(event: Event) {
event.stopPropagation();
this.promptComunitaCode();
}
async promptComunitaCode() {
const alert = await this.alertCtrl.create({
header: 'Imposta Comunità',
subHeader: 'Inserisci il codice parrocchiale/comunità per attivare il libretto dedicato:',
cssClass: 'premium-alert',
inputs: [
{
name: 'code',
type: 'text',
placeholder: 'Es: 123456',
value: this.comunitaService.comunitaCode()
}
],
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Rimuovi',
role: 'destructive',
cssClass: 'alert-button-delete',
handler: async () => {
await this.comunitaService.setComunitaCode('');
const toast = await this.toastCtrl.create({
message: 'Comunità disattivata.',
duration: 2000,
color: 'secondary'
});
await toast.present();
}
},
{
text: 'Salva',
handler: async (data) => {
const trimmed = (data.code || '').trim();
if (!trimmed) {
await this.comunitaService.setComunitaCode('');
return;
}
// Show loading overlay with progress
const loading = await this.loadingCtrl.create({
message: 'Caricamento 0%',
cssClass: 'premium-loading',
spinner: 'crescent'
});
await loading.present();
// Subscribe to progress updates
let progressInterval: any = null;
progressInterval = setInterval(() => {
const pct = this.comunitaService.loadingProgress();
loading.message = `Caricamento ${pct}%`;
if (pct >= 100) {
clearInterval(progressInterval);
}
}, 100);
const success = await this.comunitaService.setComunitaCode(trimmed);
clearInterval(progressInterval);
await loading.dismiss();
if (success) {
const toast = await this.toastCtrl.create({
message: `Comunità attivata: ${this.comunitaService.comunitaNome()}`,
duration: 2000,
color: 'success'
});
await toast.present();
} else {
const toast = await this.toastCtrl.create({
message: 'Codice non trovato o errore di connessione.',
duration: 2000,
color: 'danger'
});
await toast.present();
setTimeout(() => this.promptComunitaCode(), 500);
}
}
}
]
});
await alert.present();
}
private massCardStartX: number = 0;
private massCardStartY: number = 0;
onMassCardTouchStart(event: TouchEvent) {
if (event.touches.length === 1) {
this.massCardStartX = event.touches[0].clientX;
this.massCardStartY = event.touches[0].clientY;
}
}
onMassCardTouchEnd(event: TouchEvent) {
if (event.changedTouches.length === 1) {
const endX = event.changedTouches[0].clientX;
const endY = event.changedTouches[0].clientY;
const diffX = endX - this.massCardStartX;
const diffY = endY - this.massCardStartY;
// Ensure it is a horizontal swipe (diffX is much larger than diffY)
if (Math.abs(diffX) > 60 && Math.abs(diffY) < 40) {
// Prevent triggering click expansion
event.stopPropagation();
event.preventDefault();
if (diffX > 0) {
// Swiped right -> go to PREVIOUS date
this.navigateMassDate(-1);
} else {
// Swiped left -> go to NEXT date
this.navigateMassDate(1);
}
}
}
}
async navigateMassDate(direction: number) {
const list = this.cantiLettureService.availableMasses();
if (list.length === 0) return;
const currentDate = this.cantiLettureService.selectedMassDate();
if (!currentDate) {
this.cantiLettureService.selectedMassDate.set(list[0].date);
return;
}
const currentIndex = list.findIndex(m => m.date === currentDate);
if (currentIndex === -1) {
this.cantiLettureService.selectedMassDate.set(list[0].date);
return;
}
const nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < list.length) {
// Set the next/prev date
this.cantiLettureService.selectedMassDate.set(list[nextIndex].date);
const toast = await this.toastCtrl.create({
message: `Messa: ${list[nextIndex].day} ${this.formatCompactDate(list[nextIndex].date)}`,
duration: 1500,
color: 'secondary',
position: 'bottom'
});
await toast.present();
} else {
// "quando le messe sono finite rimani sull'ultima valorizzata, non dare errore"
const targetIndex = direction > 0 ? list.length - 1 : 0;
this.cantiLettureService.selectedMassDate.set(list[targetIndex].date);
const toast = await this.toastCtrl.create({
message: direction > 0 ? 'Nessun\'altra messa futura disponibile.' : 'Nessun\'altra messa passata disponibile.',
duration: 1500,
color: 'medium',
position: 'bottom'
});
await toast.present();
}
}
private formatCompactDate(dateStr: string): string {
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}`;
}
return dateStr;
}
}
+10 -1
View File
@@ -4,10 +4,19 @@
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon>
</div>
<ion-button fill="clear" (click)="toggleChords()" 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>
<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_canti }}</span>
<span class="canto-number" *ngIf="canto()?.id_canti" [style.color]="getCommunitySongNumber(canto()) !== null ? 'var(--ion-color-secondary)' : ''">
{{ getCommunitySongNumber(canto()) || canto()?.id_canti }}
</span>
<span class="title-text">{{ canto()?.titolo || 'Player' }}</span>
</div>
</ion-title>
+49
View File
@@ -11,6 +11,8 @@ import { ConnectivityService } from '../../services/connectivity.service';
import { PlaylistService } from '../../services/playlist.service';
import { YoutubePlayerService } from '../../services/youtube-player.service';
import { MyCantiService } from '../../services/my-canti.service';
import { ComunitaService } from '../../services/comunita.service';
import { StatsService } from '../../services/stats.service';
@Component({
selector: 'app-player',
@@ -103,11 +105,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public settingsService = inject(SettingsService);
public connectivityService = inject(ConnectivityService);
public playlistService = inject(PlaylistService);
public comunitaService = inject(ComunitaService);
private myCantiService = inject(MyCantiService);
private statsService = inject(StatsService);
private gestureCtrl = inject(GestureController);
private el = inject(ElementRef);
private sanitizer = inject(DomSanitizer);
private songStartTime: number = 0;
constructor() {
// Sync mode from global settings
effect(() => {
@@ -195,9 +201,24 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
if (found) {
this.logPreviousSongTime();
this.canto.set(found);
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);
}
}
}
});
@@ -243,11 +264,21 @@ 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());
}
}
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());
}
}
zoomIn() {
@@ -485,7 +516,25 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
}
getCommunitySongNumber(canto: any): string | null {
if (!canto) return null;
if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null;
const cantiInfo = this.comunitaService.comunitaCantiInfo();
const info = cantiInfo.find(x => x.id_canti === canto.id_canti || x.id_canti === Number(canto.id));
return info && info.num_canto ? info.num_canto.toString() : null;
}
private logPreviousSongTime() {
const c = this.canto();
if (c && this.songStartTime > 0) {
const timeSpent = (Date.now() - this.songStartTime) / 1000;
this.statsService.logSongView(c.id_canti, timeSpent);
this.songStartTime = 0;
}
}
ngOnDestroy() {
this.logPreviousSongTime();
this.audioEngine.stopListening();
this.channel.close();
}
+3 -1
View File
@@ -25,7 +25,9 @@
</div>
<div class="song-info">
<span class="canto-number">{{ song.id_canti }}</span>
<span class="song-title">{{ song.titolo }}</span>
<span class="song-title">
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
</span>
</div>
<div class="example-custom-placeholder" *cdkDragPlaceholder></div>
</div>
+9
View File
@@ -1,6 +1,7 @@
import { Component, inject, computed } from '@angular/core';
import { PlaylistService } from '../../services/playlist.service';
import { CantiService } from '../../services/canti.service';
import { ComunitaService } from '../../services/comunita.service';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { AlertController, ToastController, ModalController } from '@ionic/angular';
import { Router } from '@angular/router';
@@ -14,6 +15,7 @@ import { Router } from '@angular/router';
export class PlaylistPage {
public playlistService = inject(PlaylistService);
public cantiService = inject(CantiService);
public comunitaService = inject(ComunitaService);
private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController);
private router = inject(Router);
@@ -177,4 +179,11 @@ export class PlaylistPage {
});
await toast.present();
}
getCommunitySongNumber(song: any): string | null {
if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null;
const cantiInfo = this.comunitaService.comunitaCantiInfo();
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
return info && info.num_canto ? info.num_canto.toString() : null;
}
}
+41 -28
View File
@@ -61,37 +61,31 @@
</ion-item>
</div>
<!-- Messa di Riferimento Selection -->
<!-- Comunità -->
<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">
Messa di Riferimento (Suggeriti)
</h2>
</div>
<div class="select-item-container ion-padding-horizontal ion-padding-bottom">
<div class="select-label-row">
<ion-icon name="calendar-outline" color="secondary" class="select-icon"></ion-icon>
<div class="select-text-group">
<h2 class="settings-item-title outfit-font">Seleziona Messa</h2>
<p class="settings-item-subtitle outfit-font">Scegli la messa per i canti consigliati</p>
</div>
</div>
<div class="select-box-wrapper">
<ion-select [value]="cantiLettureService.selectedMassDate()"
(ionChange)="onMassChange($event)"
interface="action-sheet"
class="custom-select-block"
placeholder="Seleziona una messa...">
<ion-select-option *ngFor="let mass of cantiLettureService.availableMasses()" [value]="mass.date">
{{ mass.formattedLabel }}
</ion-select-option>
</ion-select>
</div>
</div>
<ion-item class="transparent-item" lines="none">
<ion-icon name="people-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Comunità</h2>
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="settingsService.toggleComunitaEnabled()" color="secondary"></ion-toggle>
</ion-item>
</div>
<!-- Invio Dati Statistici -->
<div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none">
<ion-icon name="analytics-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Invio dati statistici</h2>
<p class="settings-item-subtitle">Invia statistiche di utilizzo e tonalità</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.invioDatiStatistici()" (ionChange)="settingsService.toggleInvioDatiStatistici()" color="secondary"></ion-toggle>
</ion-item>
</div>
<!-- Display Mode Section -->
<div class="settings-group glass ion-margin-bottom">
<div class="group-header ion-padding-start ion-padding-top">
@@ -117,6 +111,8 @@
</div>
<div class="settings-group glass ion-margin-top" *ngIf="settingsService.showEditor()">
<ion-item class="transparent-item" lines="none" (click)="myCantiService.sendAllMyCanti()" detail="true" button *ngIf="myCantiService.myCanti().length > 0">
<ion-icon name="send-outline" slot="start" color="secondary"></ion-icon>
@@ -185,6 +181,23 @@
</div>
</div>
<div class="legend-item">
<div style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
<div class="legend-icon-wrapper">
<ion-icon name="text-outline" color="medium"></ion-icon>
</div>
<div class="legend-icon-wrapper">
<ion-icon name="musical-notes-outline" color="secondary"></ion-icon>
</div>
</div>
<div class="legend-text" style="padding-top: 6px;">
<h4 class="outfit-font">Testo / Accordi (icona in alto a destra)</h4>
<p class="outfit-font">
Alterna la visualizzazione tra solo testo e testo con accordi. L'icona con la <strong>T</strong> (grigia) indica la modalità testo; l'icona con le note musicali (arancione) indica la modalità accordi. Un tocco cambia modalità all'istante.
</p>
</div>
</div>
<div class="legend-item">
<div class="legend-icon-wrapper">
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
+9 -1
View File
@@ -11,6 +11,7 @@ import { Router } from '@angular/router';
import { MyCantiService } from '../../services/my-canti.service';
import { CantiLettureService } from '../../services/canti-letture.service';
import { ComunitaService } from '../../services/comunita.service';
@Component({
selector: 'app-settings',
@@ -26,6 +27,7 @@ export class SettingsPage {
public connectivityService = inject(ConnectivityService);
public playlistService = inject(PlaylistService);
public cantiLettureService = inject(CantiLettureService);
public comunitaService = inject(ComunitaService);
private swUpdate = inject(SwUpdate);
private toastCtrl = inject(ToastController);
private modalCtrl = inject(ModalController);
@@ -47,6 +49,13 @@ export class SettingsPage {
async fullRefresh() {
// 1. Refresh JSON data
this.cantiService.refresh();
// 2. Refresh liturgical readings JSON
try {
await this.cantiLettureService.fetchData();
} catch (err) {
console.error('Failed to refresh liturgical readings:', err);
}
// 2. Check for Service Worker updates
if (this.swUpdate.isEnabled) {
@@ -77,5 +86,4 @@ export class SettingsPage {
});
await toast.present();
}
}
+5 -5
View File
@@ -180,20 +180,20 @@ export class CantiLettureService {
this.availableMasses.set(massesList);
// If no mass is currently selected, select the first one (or today's mass if exists)
if (!this.selectedMassDate() && massesList.length > 0) {
// Try to find today's date
// Always select today's mass if available on load, else find closest future date
if (massesList.length > 0) {
const todayStr = new Date().toISOString().split('T')[0];
const match = massesList.find(m => m.date === todayStr);
if (match) {
this.selectedMassDate.set(match.date);
} else {
// Find the first date that is today or in the future
// If today is not present, find the first date that is in the future
const futureMatch = massesList.find(m => m.date >= todayStr);
if (futureMatch) {
this.selectedMassDate.set(futureMatch.date);
} else {
this.selectedMassDate.set(massesList[0].date);
// Otherwise fall back to the last available mass date (closest to today)
this.selectedMassDate.set(massesList[massesList.length - 1].date);
}
}
}
+194
View File
@@ -0,0 +1,194 @@
import { Injectable, signal, inject, effect } from '@angular/core';
import { HttpClient, HttpRequest, HttpEventType, HttpResponse } from '@angular/common/http';
import { SettingsService } from './settings.service';
export interface ParrocchiaItem {
id_parrocchia: number;
nome: string;
codice: string;
mail: string;
guid_parrocchia: string;
}
export interface ParrocchiaCantiItem {
id_parrocchia: number;
id_canti: number;
num_canto: number;
}
export interface CantiSettingsItem {
id_canti: number;
speed: number;
tonalita: number;
}
export interface GetAllAppTablesResponse {
parrocchia?: { data: ParrocchiaItem[] };
parrocchia_canti?: { data: ParrocchiaCantiItem[] };
canti_settings?: { data: CantiSettingsItem[] };
}
@Injectable({
providedIn: 'root'
})
export class ComunitaService {
private http = inject(HttpClient);
private settingsService = inject(SettingsService);
public comunitaCode = signal<string>('');
public comunitaNome = signal<string>('');
public comunitaMail = signal<string>('');
public comunitaCantiIds = signal<(number | string)[]>([]);
public comunitaCantiInfo = signal<ParrocchiaCantiItem[]>([]);
public comunitaCantiSettings = signal<CantiSettingsItem[]>([]);
public isFilterActive = signal<boolean>(false);
public loading = signal<boolean>(false);
public loadingProgress = signal<number>(0);
constructor() {
const savedCode = localStorage.getItem('comunita-code');
const savedNome = localStorage.getItem('comunita-nome');
const savedMail = localStorage.getItem('comunita-mail');
const savedCanti = localStorage.getItem('comunita-canti-ids');
const savedInfo = localStorage.getItem('comunita-canti-info');
const savedSettings = localStorage.getItem('comunita-canti-settings');
const savedFilterActive = localStorage.getItem('comunita-filter-active') === 'true';
if (savedCode) this.comunitaCode.set(savedCode);
if (savedNome) this.comunitaNome.set(savedNome);
if (savedMail) this.comunitaMail.set(savedMail);
if (savedCanti) {
try { this.comunitaCantiIds.set(JSON.parse(savedCanti)); } catch (e) {}
}
if (savedInfo) {
try { this.comunitaCantiInfo.set(JSON.parse(savedInfo)); } catch (e) {}
}
if (savedSettings) {
try { this.comunitaCantiSettings.set(JSON.parse(savedSettings)); } catch (e) {}
}
this.isFilterActive.set(savedFilterActive);
effect(() => {
localStorage.setItem('comunita-code', this.comunitaCode());
localStorage.setItem('comunita-nome', this.comunitaNome());
localStorage.setItem('comunita-mail', this.comunitaMail());
localStorage.setItem('comunita-canti-ids', JSON.stringify(this.comunitaCantiIds()));
localStorage.setItem('comunita-canti-info', JSON.stringify(this.comunitaCantiInfo()));
localStorage.setItem('comunita-canti-settings', JSON.stringify(this.comunitaCantiSettings()));
localStorage.setItem('comunita-filter-active', this.isFilterActive().toString());
});
effect(() => {
if (!this.settingsService.comunitaEnabled()) {
this.isFilterActive.set(false);
}
});
}
private fetchWithProgress<T>(url: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const req = new HttpRequest('GET', url, {
reportProgress: true,
responseType: 'json'
});
this.http.request<T>(req).subscribe({
next: (event) => {
if (event.type === HttpEventType.DownloadProgress) {
if (event.total && event.total > 0) {
const pct = Math.round((event.loaded / event.total) * 100);
this.loadingProgress.set(pct);
} else {
// No Content-Length header — simulate gradual progress up to 85%
const simulated = Math.min(85, this.loadingProgress() + 12);
this.loadingProgress.set(simulated);
}
} else if (event instanceof HttpResponse) {
this.loadingProgress.set(100);
resolve(event.body as T);
}
},
error: (err) => reject(err)
});
});
}
async setComunitaCode(code: string): Promise<boolean> {
const trimmedCode = code.trim();
if (!trimmedCode) {
this.comunitaCode.set('');
this.comunitaNome.set('');
this.comunitaMail.set('');
this.comunitaCantiIds.set([]);
this.comunitaCantiInfo.set([]);
this.comunitaCantiSettings.set([]);
this.isFilterActive.set(false);
return true;
}
this.loading.set(true);
this.loadingProgress.set(0);
// 1. Try to fetch from the actual production API
try {
const url = `https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=${trimmedCode}&all_song=false&t=${Date.now()}`;
const data = await this.fetchWithProgress<GetAllAppTablesResponse>(url);
if (data && data.parrocchia?.data && data.parrocchia.data.length > 0) {
const parrocchia = data.parrocchia.data[0];
const cantiList = data.parrocchia_canti?.data || [];
const settingsList = data.canti_settings?.data || [];
this.comunitaCode.set(trimmedCode);
this.comunitaNome.set(parrocchia.nome || `Comunità ${trimmedCode}`);
this.comunitaMail.set(parrocchia.mail || '');
this.comunitaCantiInfo.set(cantiList);
this.comunitaCantiIds.set(cantiList.map(c => c.id_canti));
this.comunitaCantiSettings.set(settingsList);
this.isFilterActive.set(true);
this.loading.set(false);
return true;
}
} catch (err) {
console.warn('Failed to fetch from production API, trying fallback...', err);
this.loadingProgress.set(0);
}
// 2. Fallback to static community JSON
try {
const isProduction = window.location.hostname.includes('canticristiani.it');
const origin = isProduction ? window.location.origin : 'https://www.canticristiani.it';
const fallbackUrl = `${origin}/api/comunita_${trimmedCode}.json?t=${Date.now()}`;
interface StaticComunitaData {
id_comunita: string;
nome_comunita: string;
canti: (number | string)[];
}
const staticData = await this.fetchWithProgress<StaticComunitaData>(fallbackUrl);
if (staticData && staticData.canti) {
this.comunitaCode.set(trimmedCode);
this.comunitaNome.set(staticData.nome_comunita || `Comunità ${trimmedCode}`);
this.comunitaCantiIds.set(staticData.canti);
const mockInfo = staticData.canti.map((id, index) => ({
id_parrocchia: 1,
id_canti: Number(id),
num_canto: index + 1
}));
this.comunitaCantiInfo.set(mockInfo);
this.comunitaCantiSettings.set([]);
this.isFilterActive.set(true);
this.loading.set(false);
return true;
}
} catch (err) {
console.error('All community fetch strategies failed:', err);
}
this.loading.set(false);
return false;
}
}
+78 -11
View File
@@ -11,7 +11,14 @@ export class SettingsService {
public fullscreenMode = signal<boolean>(false);
/** Browser Fullscreen state: true = fullscreen active */
public browserFullscreen = signal<boolean>(!!document.fullscreenElement);
public browserFullscreen = signal<boolean>(
!!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
)
);
/** Avanzamento automatico: true = passa al brano successivo automaticamente */
public autoAdvance = signal<boolean>(true);
@@ -22,6 +29,12 @@ export class SettingsService {
/** Schermo sempre acceso: true = attiva Screen Wake Lock */
public keepScreenOn = signal<boolean>(false);
/** Funzionalità Comunità: true = il chip comunità è visibile nella home */
public comunitaEnabled = signal<boolean>(true);
/** Invio dati statistici: true = invia pacchetto dati statistici */
public invioDatiStatistici = signal<boolean>(false);
private wakeLock: any = null;
constructor() {
@@ -53,10 +66,31 @@ export class SettingsService {
this.keepScreenOn.set(savedKeepScreenOn === 'true');
}
// Sync browser fullscreen state with listeners
document.addEventListener('fullscreenchange', () => {
this.browserFullscreen.set(!!document.fullscreenElement);
});
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
if (savedComunitaEnabled !== null) {
this.comunitaEnabled.set(savedComunitaEnabled === 'true');
}
const savedStats = localStorage.getItem('invio-dati-statistici');
if (savedStats !== null) {
this.invioDatiStatistici.set(savedStats === 'true');
}
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
const updateFullscreenState = () => {
const isFs = !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
);
this.browserFullscreen.set(isFs);
};
document.addEventListener('fullscreenchange', updateFullscreenState);
document.addEventListener('webkitfullscreenchange', updateFullscreenState);
document.addEventListener('mozfullscreenchange', updateFullscreenState);
document.addEventListener('MSFullscreenChange', updateFullscreenState);
effect(() => {
localStorage.setItem('show-chords-default', this.showChordsDefault().toString());
@@ -110,6 +144,12 @@ export class SettingsService {
this.keepScreenOn.update(v => !v);
}
toggleComunitaEnabled() {
const newValue = !this.comunitaEnabled();
this.comunitaEnabled.set(newValue);
localStorage.setItem('comunita-enabled', newValue.toString());
}
private async requestWakeLock() {
if ('wakeLock' in navigator) {
try {
@@ -128,14 +168,41 @@ export class SettingsService {
}
toggleBrowserFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
});
const isFs = !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
);
if (!isFs) {
const docEl = document.documentElement as any;
if (docEl.requestFullscreen) {
docEl.requestFullscreen().catch((err: any) => console.error(err));
} else if (docEl.webkitRequestFullscreen) {
docEl.webkitRequestFullscreen();
} else if (docEl.mozRequestFullScreen) {
docEl.mozRequestFullScreen();
} else if (docEl.msRequestFullscreen) {
docEl.msRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
const doc = document as any;
if (doc.exitFullscreen) {
doc.exitFullscreen().catch((err: any) => console.error(err));
} else if (doc.webkitExitFullscreen) {
doc.webkitExitFullscreen();
} else if (doc.mozCancelFullScreen) {
doc.mozCancelFullScreen();
} else if (doc.msExitFullscreen) {
doc.msExitFullscreen();
}
}
}
toggleInvioDatiStatistici() {
const newValue = !this.invioDatiStatistici();
this.invioDatiStatistici.set(newValue);
localStorage.setItem('invio-dati-statistici', newValue.toString());
}
}
+192
View File
@@ -0,0 +1,192 @@
import { Injectable, inject, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SettingsService } from './settings.service';
import { ConnectivityService } from './connectivity.service';
import { ComunitaService } from './comunita.service';
import { VERSION } from '../version';
@Injectable({
providedIn: 'root'
})
export class StatsService {
private http = inject(HttpClient);
private settingsService = inject(SettingsService);
private connectivityService = inject(ConnectivityService);
private comunitaService = inject(ComunitaService);
constructor() {
// Automatically trigger sync when the device becomes online
effect(() => {
if (this.connectivityService.isOnline()) {
this.syncStats();
}
});
}
private getOrCreateUUID(): string {
let uuid = localStorage.getItem('stats-uuid');
if (!uuid) {
uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
localStorage.setItem('stats-uuid', uuid);
}
return uuid;
}
private formatDateTime(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
const y = date.getFullYear();
const m = pad(date.getMonth() + 1);
const d = pad(date.getDate());
const h = date.getHours();
const min = pad(date.getMinutes());
const s = pad(date.getSeconds());
return `${y}-${m}-${d} ${h}:${min}:${s}`;
}
private getBrowserModel(): string {
const ua = navigator.userAgent;
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Safari') && !ua.includes('Chrome')) return 'Safari';
if (ua.includes('Firefox')) return 'Firefox';
return 'Browser';
}
private getLocalLogs(): any[] {
const data = localStorage.getItem('stats-logs-queue');
return data ? JSON.parse(data) : [];
}
private saveLocalLogs(logs: any[]) {
localStorage.setItem('stats-logs-queue', JSON.stringify(logs));
}
private clearLocalLogs() {
localStorage.removeItem('stats-logs-queue');
}
private getLocalSongSettings(): any[] {
const data = localStorage.getItem('stats-settings-queue');
return data ? JSON.parse(data) : [];
}
private saveLocalSongSettings(settings: any[]) {
localStorage.setItem('stats-settings-queue', JSON.stringify(settings));
}
logSongView(idCanti: number, timeSpentSeconds: number) {
if (!this.settingsService.invioDatiStatistici()) return;
if (timeSpentSeconds < 1) return; // Ignore very short views
console.log('📊 StatsService: logSongView called for song', idCanti, 'timespent:', timeSpentSeconds);
const logEntry = {
uuid: this.getOrCreateUUID(),
id_canti: idCanti.toString(),
data_log: this.formatDateTime(new Date()),
version: VERSION,
tempo: Math.round(timeSpentSeconds),
lat: null,
lon: null,
json: JSON.stringify({
model: this.getBrowserModel(),
height: window.innerHeight,
width: window.innerWidth
}),
stato: 0,
id: Math.floor(Math.random() * 100000)
};
const logs = this.getLocalLogs();
logs.push(logEntry);
this.saveLocalLogs(logs);
this.syncStats();
}
updateSongSettings(idCanti: number, tonalita: number, speed: number = 0) {
if (!this.settingsService.invioDatiStatistici()) return;
console.log('📊 StatsService: updateSongSettings called for song', idCanti, 'tonalita:', tonalita);
const settingsList = this.getLocalSongSettings();
const idx = settingsList.findIndex(s => s.id_canti === idCanti);
if (idx !== -1) {
settingsList[idx].tonalita = tonalita;
settingsList[idx].speed = speed;
} else {
settingsList.push({
id_canti: idCanti,
speed: speed,
tonalita: tonalita,
id: Math.floor(Math.random() * 100000)
});
}
this.saveLocalSongSettings(settingsList);
this.syncStats();
}
private clearLocalStats() {
localStorage.removeItem('stats-logs-queue');
localStorage.removeItem('stats-settings-queue');
}
syncStats() {
if (!this.settingsService.invioDatiStatistici()) {
console.log('📊 StatsService: Sync ignored (settings disabled)');
return;
}
if (!this.connectivityService.isOnline()) {
console.log('📊 StatsService: Sync postponed (offline)');
return;
}
const logs = this.getLocalLogs();
const settings = this.getLocalSongSettings();
console.log('📊 StatsService: syncStats checking queues. Logs:', logs.length, 'Settings:', settings.length);
if (logs.length === 0 && settings.length === 0) {
return;
}
const uuid = this.getOrCreateUUID();
const email = this.comunitaService.comunitaMail() || `pwa-${uuid.substring(0,8)}@librettocanti.it`;
const payload = {
uuid: uuid,
log: logs,
pref: [],
canti_settings: settings,
user_devices: {
uuid: uuid,
email: email,
platform: 'browser',
version: VERSION,
gruppo: (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) ? this.comunitaService.comunitaCode() : '',
all_song: false,
deleteList: false,
deletePref: false,
data_app_sync: this.formatDateTime(new Date())
}
};
const url = `https://api.librettocanti.it/canti/api/v1/updateOnlineDB?uuid=${uuid}&email=${email}`;
console.log('📊 StatsService: Sending POST request to:', url, 'with payload:', payload);
this.http.post(url, payload).subscribe({
next: (res) => {
this.clearLocalStats();
console.log('📊 StatsService: Sync successful!', res);
},
error: (err) => {
console.error('📊 StatsService: Sync failed, keeping in local buffer...', err);
}
});
}
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.05.18.0125';
export const VERSION = '2026.05.18.1550';