Ottimizzazione UI player, rotazione head tilt invertita, prefisso playlist in Home e implementazione Identity QR modal e interceptor API

This commit is contained in:
David Frassi
2026-06-12 17:13:46 +02:00
parent 4d1883a45d
commit d023bb7d3f
31 changed files with 1706 additions and 160 deletions
+2
View File
@@ -2,3 +2,5 @@ FTP_PASSWORD=cantiDavid@72
FTP_THREADS=30 FTP_THREADS=30
BASE_HREF=/ionic/ BASE_HREF=/ionic/
CONTACT_EMAIL=info@canticristiani.it CONTACT_EMAIL=info@canticristiani.it
API_AUTH_USER=canti
API_AUTH_PASS=antani2026
+2 -2
View File
@@ -12,7 +12,7 @@ fi
FTP_HOST="ftp.canticristiani.it" FTP_HOST="ftp.canticristiani.it"
FTP_USER="canticristiani.it" FTP_USER="canticristiani.it"
FTP_PASS="$FTP_PASSWORD" FTP_PASS="$FTP_PASSWORD"
SOURCE_URL="http://185.193.67.105:3000/cantiletture.json" SOURCE_URL="https://api.canticristiani.it/cantiletture.json"
REMOTE_PATH="api/cantiletture.json" REMOTE_PATH="api/cantiletture.json"
if [ -z "$FTP_PASS" ]; then if [ -z "$FTP_PASS" ]; then
@@ -23,7 +23,7 @@ fi
# --- Download del file --- # --- Download del file ---
echo "⬇️ Scaricamento dati da $SOURCE_URL..." echo "⬇️ Scaricamento dati da $SOURCE_URL..."
TEMP_FILE=$(mktemp) TEMP_FILE=$(mktemp)
if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"; then
echo "❌ Errore nel download dei dati." echo "❌ Errore nel download dei dati."
rm "$TEMP_FILE" rm "$TEMP_FILE"
exit 1 exit 1
+22
View File
@@ -17,6 +17,28 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
echo "export const VERSION = '$VERSION';" > src/app/version.ts echo "export const VERSION = '$VERSION';" > src/app/version.ts
echo "🏷️ Versione aggiornata a: $VERSION" echo "🏷️ Versione aggiornata a: $VERSION"
# --- Caricamento variabili d'ambiente ---
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
# --- Configurazione Email e API Parametriche ---
node -e "
const fs = require('fs');
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
const apiUser = process.env.API_AUTH_USER || 'canti';
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
if (fs.existsSync(file)) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
fs.writeFileSync(file, content, 'utf8');
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
}
});
"
# 2. Build dell'applicazione # 2. Build dell'applicazione
echo "📦 Compilazione in corso (Production Build)..." echo "📦 Compilazione in corso (Production Build)..."
+8 -3
View File
@@ -9,7 +9,8 @@ else
fi fi
# --- Configurazione Parallelismo --- # --- Configurazione Parallelismo ---
THREADS=${1:-${FTP_THREADS:-30}} THREADS=${1:-${FTP_THREADS:-100}}
export FTP_THREADS=$THREADS
# --- Configurazione FTP per ROOT www.canticristiani.it --- # --- Configurazione FTP per ROOT www.canticristiani.it ---
FTP_HOST="ftp.canticristiani.it" FTP_HOST="ftp.canticristiani.it"
@@ -27,16 +28,20 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
echo "export const VERSION = '$VERSION';" > src/app/version.ts echo "export const VERSION = '$VERSION';" > src/app/version.ts
echo "🏷️ Versione aggiornata a: $VERSION" echo "🏷️ Versione aggiornata a: $VERSION"
# --- Configurazione Email Parametrica --- # --- Configurazione Email e API Parametriche ---
node -e " node -e "
const fs = require('fs'); const fs = require('fs');
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it'; const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
const apiUser = process.env.API_AUTH_USER || 'canti';
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => { ['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
let content = fs.readFileSync(file, 'utf8'); let content = fs.readFileSync(file, 'utf8');
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`); content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
fs.writeFileSync(file, content, 'utf8'); fs.writeFileSync(file, content, 'utf8');
console.log(\`📧 Aggiornata email di contatto in \${file} a: \${envEmail}\`); console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
} }
}); });
" "
+6
View File
@@ -6,6 +6,12 @@
"start_url": "/", "start_url": "/",
"theme_color": "#3880ff", "theme_color": "#3880ff",
"background_color": "#ffffff", "background_color": "#ffffff",
"protocol_handlers": [
{
"protocol": "web+canti",
"url": "/?url=%s"
}
],
"icons": [ "icons": [
{ {
"src": "assets/icons/icon-72x72.png", "src": "assets/icons/icon-72x72.png",
+18
View File
@@ -1,4 +1,22 @@
<ion-app> <ion-app>
<ion-router-outlet></ion-router-outlet> <ion-router-outlet></ion-router-outlet>
<div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div> <div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div>
<!-- Unified Setup Loader Overlay -->
<div *ngIf="!cantiService.firstLoadCompleted()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: #121212; color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif;">
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; -webkit-font-smoothing: antialiased;">Setup in corso</h2>
<p style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; -webkit-font-smoothing: antialiased;">Configurazione iniziale e caricamento canti...</p>
<div style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; -webkit-font-smoothing: antialiased; font-weight: 500;">Versione {{ version }}</div>
<div style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px;">Fase: Setup</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div [style.width.%]="cantiService.progress()" style="background: #e67e22; height: 100%; transition: width 0.15s ease-out; border-radius: 10px;"></div>
</div>
<div style="font-size: 1.2rem; font-weight: 700; color: #e67e22; -webkit-font-smoothing: antialiased;">{{ cantiService.progress() }}%</div>
</div>
</div>
</ion-app> </ion-app>
+118 -4
View File
@@ -1,5 +1,9 @@
import { Component, inject } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { ThemeService } from './services/theme.service'; import { ThemeService } from './services/theme.service';
import { CantiService } from './services/canti.service';
import { VERSION } from './version';
import { Router, ActivatedRoute } from '@angular/router';
import { ToastController } from '@ionic/angular';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -7,14 +11,100 @@ import { ThemeService } from './services/theme.service';
styleUrls: ['app.component.scss'], styleUrls: ['app.component.scss'],
standalone: false, standalone: false,
}) })
export class AppComponent { export class AppComponent implements OnInit {
private themeService = inject(ThemeService); // Ensures theme is initialized at boot private themeService = inject(ThemeService); // Ensures theme is initialized at boot
public cantiService = inject(CantiService);
public version = VERSION;
private router = inject(Router);
private route = inject(ActivatedRoute);
private toastCtrl = inject(ToastController);
constructor() { constructor() {
// Gli aggiornamenti automatici e periodici sono stati rimossi. // Gli aggiornamenti automatici e periodici sono stati rimossi.
// L'aggiornamento viene gestito esclusivamente in modo manuale // L'aggiornamento viene gestito esclusivamente in modo manuale
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage. // tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
} }
ngOnInit() {
this.route.queryParams.subscribe(params => {
const protocolUrl = params['url'];
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
try {
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
const urlObj = new URL(cleanUrl);
let targetPath = urlObj.pathname;
if (targetPath === '/open' || targetPath === '//open') {
targetPath = '/';
} else if (targetPath.startsWith('/open/')) {
targetPath = targetPath.substring(5);
}
const queryParams: any = {};
urlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
} catch (e) {
console.error('Failed to parse protocol url:', protocolUrl, e);
}
}
});
this.checkAndRedirectToPwa();
}
async checkAndRedirectToPwa() {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
if (isStandalone) {
return;
}
// Check if we have sharing/navigation query parameters
const urlParams = new URLSearchParams(window.location.search);
const hasSharing = urlParams.has('import') || urlParams.has('playlist-uid') || urlParams.has('restore-uid') || urlParams.has('id');
if (hasSharing) {
let isInstalled = false;
if ('getInstalledRelatedApps' in navigator) {
try {
const relatedApps = await (navigator as any).getInstalledRelatedApps();
isInstalled = relatedApps.length > 0;
} catch (e) {
console.warn('Failed to check installed apps:', e);
}
}
const search = window.location.search;
const path = window.location.pathname;
const protocolLink = `web+canti://open${path}${search}`;
if (isInstalled) {
window.location.href = protocolLink;
} else {
const toast = await this.toastCtrl.create({
header: 'Apri nell\'App CantiCristiani',
message: 'Usa la PWA installata per visualizzare questo contenuto ed evitare la cache del browser.',
position: 'top',
color: 'warning',
buttons: [
{
text: 'APRI APP',
handler: () => {
window.location.href = protocolLink;
}
},
{
text: 'Nascondi',
role: 'cancel'
}
]
});
await toast.present();
}
}
}
} }
export function showFullscreenUpdateOverlay() { export function showFullscreenUpdateOverlay() {
@@ -37,8 +127,13 @@ export function showFullscreenUpdateOverlay() {
overlay.innerHTML = ` overlay.innerHTML = `
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;"> <div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 10px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Aggiornamento in corso</h2> <div style="margin-bottom: 25px; display: inline-block;">
<p style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 30px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Installazione della nuova versione...</p> <img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Download aggiornamento</h2>
<p style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Scaricamento della nuova versione...</p>
<div id="pwa-update-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Download</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;"> <div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-update-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div> <div id="pwa-update-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div> </div>
@@ -47,6 +142,25 @@ export function showFullscreenUpdateOverlay() {
`; `;
document.body.appendChild(overlay); document.body.appendChild(overlay);
// Fetch remote version to display the version being downloaded
fetch(`/version.json?cb=${Date.now()}`)
.then(res => {
if (res.ok) return res.json();
throw new Error('Fallback');
})
.then(data => {
const versionEl = document.getElementById('pwa-update-version');
if (data && data.version && versionEl) {
versionEl.textContent = 'Versione ' + data.version;
}
})
.catch(() => {
const versionEl = document.getElementById('pwa-update-version');
if (versionEl) {
versionEl.textContent = '';
}
});
let percent = 0; let percent = 0;
const interval = setInterval(() => { const interval = setInterval(() => {
if (percent < 95) { if (percent < 95) {
+6 -2
View File
@@ -1,7 +1,7 @@
import { NgModule, isDevMode } from '@angular/core'; import { NgModule, isDevMode } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router'; import { RouteReuseStrategy } from '@angular/router';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { IonicStorageModule } from '@ionic/storage-angular'; import { IonicStorageModule } from '@ionic/storage-angular';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
@@ -9,6 +9,7 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor';
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
@@ -24,7 +25,10 @@ import { ServiceWorkerModule } from '@angular/service-worker';
registrationStrategy: 'registerImmediately' registrationStrategy: 'registerImmediately'
}) })
], ],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }], providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{ provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true }
],
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })
export class AppModule {} export class AppModule {}
@@ -0,0 +1,41 @@
<ion-header class="ion-no-border">
<ion-toolbar class="bg-gradient">
<ion-title class="outfit-font">QR Code di Ripristino</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="bg-gradient ion-padding">
<div class="qr-container">
<p class="description outfit-font">
Salva questo QR Code (fai uno screenshot o scaricalo) per ripristinare il tuo account e le tue comunità se cambi dispositivo o reinstalli l'applicazione.
</p>
<div class="qr-card glass">
<div class="qr-wrapper" *ngIf="qrCodeUrl">
<img [src]="qrCodeUrl" alt="QR Code di Ripristino" class="qr-image" />
</div>
<div class="uuid-box">
<span class="uuid-label outfit-font">Codice Identificativo:</span>
<span class="uuid-text select-all outfit-font">{{ userUuid }}</span>
</div>
</div>
<div class="actions-wrapper">
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
<ion-icon name="copy-outline" slot="start"></ion-icon>
Copia Codice Testuale
</ion-button>
<a *ngIf="qrCodeUrl" [href]="qrCodeUrl" download="ripristino-identita-canti.png" class="download-btn outfit-font">
<ion-icon name="download-outline"></ion-icon>
Scarica Immagine QR
</a>
</div>
</div>
</ion-content>
@@ -0,0 +1,121 @@
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 8px;
text-align: center;
min-height: 100%;
}
.description {
font-size: 0.95rem;
line-height: 1.5;
color: var(--ion-text-color);
opacity: 0.9;
margin-bottom: 24px;
max-width: 320px;
}
.qr-card {
padding: 24px;
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 24px;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
}
.qr-wrapper {
background: white;
padding: 12px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 220px;
height: 220px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.uuid-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.uuid-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ion-color-secondary);
font-weight: 600;
}
.uuid-text {
font-size: 0.8rem;
color: var(--ion-text-color);
word-break: break-all;
opacity: 0.85;
font-family: monospace;
user-select: all;
background: rgba(var(--ion-text-color-rgb, 255, 255, 255), 0.04);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.actions-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 340px;
}
.action-btn {
margin: 0;
--border-radius: 14px;
--box-shadow: 0 4px 16px rgba(var(--ion-color-secondary-rgb), 0.2);
font-weight: 600;
height: 48px;
}
.download-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
background: rgba(255, 255, 255, 0.06);
color: var(--ion-text-color);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 12px;
border-radius: 14px;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s ease;
ion-icon {
font-size: 1.2rem;
color: var(--ion-color-secondary);
}
&:active {
background: rgba(255, 255, 255, 0.12);
}
}
@@ -0,0 +1,58 @@
import { Component, OnInit, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-identity-qr-modal',
templateUrl: './identity-qr-modal.component.html',
styleUrls: ['./identity-qr-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule]
})
export class IdentityQrModalComponent implements OnInit {
private modalCtrl = inject(ModalController);
private toastCtrl = inject(ToastController);
@Input() userUuid!: string;
public qrCodeUrl: string = '';
ngOnInit() {
this.generateQr();
}
async generateQr() {
try {
this.qrCodeUrl = await QRCode.toDataURL(this.userUuid, {
errorCorrectionLevel: 'H',
margin: 2,
width: 400,
color: {
dark: '#1e293b', // Slate 800 for premium dark aesthetic contrast
light: '#ffffff'
}
});
} catch (err) {
console.error('Failed to generate QR Code:', err);
}
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.userUuid);
const toast = await this.toastCtrl.create({
message: 'Codice copiato negli appunti!',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
} catch (err) {
console.error('Failed to copy text:', err);
}
}
dismiss() {
this.modalCtrl.dismiss();
}
}
+34 -15
View File
@@ -7,7 +7,9 @@
<span class="app-name">{{ appName }}</span> <span class="app-name">{{ appName }}</span>
<span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;"> <span class="version-badge" (click)="checkForAppUpdate($event)" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
v{{ version }} v{{ version }}
<ion-icon name="refresh-outline" style="font-size: 0.9em;"></ion-icon> <span *ngIf="hasUpdateAvailable()" style="display: inline-flex; align-items: center; justify-content: center; padding: 4px; margin-left: 2px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 24px; height: 24px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3);">
<ion-icon name="refresh-outline" style="font-size: 1.15rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
</span>
</span> </span>
</div> </div>
</div> </div>
@@ -68,14 +70,13 @@
</div> </div>
<ion-button <ion-button
*ngIf="settingsService.showEditor()" [fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
[fill]="showOnlyMine() ? 'solid' : 'outline'"
size="small" size="small"
(click)="toggleOnlyMine()" (click)="toggleFilterType('lista_completa')"
color="secondary"
class="filter-chip"> class="filter-chip">
Miei {{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)"> <ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon>
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)">
<ion-icon slot="end" name="close-circle"></ion-icon> <ion-icon slot="end" name="close-circle"></ion-icon>
</span> </span>
</ion-button> </ion-button>
@@ -85,7 +86,7 @@
size="small" size="small"
(click)="toggleFilterType('liturgico')" (click)="toggleFilterType('liturgico')"
class="filter-chip"> class="filter-chip">
{{ selectedLiturgico() !== null ? getSelectedLiturgicoLabel() : 'Liturgia' }} {{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon> <ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)"> <span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
<ion-icon slot="end" name="close-circle"></ion-icon> <ion-icon slot="end" name="close-circle"></ion-icon>
@@ -97,7 +98,7 @@
size="small" size="small"
(click)="toggleFilterType('tematico')" (click)="toggleFilterType('tematico')"
class="filter-chip"> class="filter-chip">
{{ selectedTematico() !== null ? getSelectedTematicoLabel() : 'Periodo' }} {{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon> <ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)"> <span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
<ion-icon slot="end" name="close-circle"></ion-icon> <ion-icon slot="end" name="close-circle"></ion-icon>
@@ -120,7 +121,7 @@
size="small" size="small"
(click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()" (click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()"
class="filter-chip"> class="filter-chip">
{{ playlistService.activeListName() !== null ? playlistService.activeListName() : 'Playlist' }} {{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length > 0"></ion-icon> <ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length > 0"></ion-icon>
<ion-icon slot="end" name="add-circle-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length === 0"></ion-icon> <ion-icon slot="end" name="add-circle-outline" *ngIf="playlistService.activeListName() === null && playlistService.allPlaylists().length === 0"></ion-icon>
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)"> <span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
@@ -145,7 +146,7 @@
<!-- Category Scroll (Dropdown style) --> <!-- Category Scroll (Dropdown style) -->
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="activeFilterType() || playlistService.selectionMode() || (playlistService.activeListName() && !playlistService.selectionMode())"> <ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="activeFilterType() || playlistService.selectionMode()">
<div class="momento-scroll"> <div class="momento-scroll">
<!-- Selection Mode Actions --> <!-- Selection Mode Actions -->
<div class="selection-pill glass" *ngIf="playlistService.selectionMode()"> <div class="selection-pill glass" *ngIf="playlistService.selectionMode()">
@@ -162,7 +163,7 @@
</div> </div>
<!-- Active Playlist Actions --> <!-- Active Playlist Actions -->
<div class="selection-pill glass" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()"> <div class="selection-pill glass" *ngIf="activeFilterType() === 'playlist' && playlistService.activePlaylistId() && !playlistService.selectionMode()">
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()"> <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>
@@ -170,11 +171,27 @@
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon> <ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()"> <ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon> <ion-icon name="trash-outline" slot="icon-only"></ion-icon>
</ion-button> </ion-button>
</div> </div>
<!-- Filter chips --> <!-- Filter chips -->
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
<div
class="momento-chip glass"
[class.active]="showValidati()"
(click)="toggleValidati()">
Validati
</div>
<div
class="momento-chip glass"
[class.active]="showNonValidati()"
(click)="toggleNonValidati()">
Non Validati
</div>
</ng-container>
<ng-container *ngIf="activeFilterType() !== 'lista_completa'">
<div <div
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))" *ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
class="momento-chip glass" class="momento-chip glass"
@@ -184,6 +201,7 @@
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon> <ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }} {{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
</div> </div>
</ng-container>
</div> </div>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
@@ -284,7 +302,7 @@
<div class="item-wrapper"> <div class="item-wrapper">
<!-- Selection Area --> <!-- Selection Area -->
<div class="selection-column" (click)="playlistService.toggleSongSelection(canto.id); $event.stopPropagation()"> <div class="selection-column" (click)="toggleSongSelection(canto.id); $event.stopPropagation()">
<span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)"> <span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)">
{{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }} {{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
</span> </span>
@@ -297,7 +315,8 @@
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary); display: flex; align-items: center; flex-wrap: wrap; gap: 4px;"> <h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary); display: flex; align-items: center; flex-wrap: wrap; gap: 4px;">
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span> <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 }}</span> <span>{{ canto.titolo }}</span>
<span *ngIf="canto.nonValidato" class="non-validato-badge">Non Validato</span> <span *ngIf="canto.nonValidato && !canto.id.startsWith('my_')" class="non-validato-badge remote-badge">Remoto</span>
<span *ngIf="canto.id.startsWith('my_')" class="non-validato-badge mio-badge">Mio</span>
</h2> </h2>
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;"> <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> <span>{{ canto.autore || 'Autore sconosciuto' }}</span>
+12
View File
@@ -946,12 +946,24 @@ ion-title {
margin-left: 6px; margin-left: 6px;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
&.mio-badge {
background: rgba(var(--ion-color-secondary-rgb), 0.15);
color: var(--ion-color-secondary);
border-color: rgba(var(--ion-color-secondary-rgb), 0.35);
}
} }
:host-context(body.high-contrast) .non-validato-badge { :host-context(body.high-contrast) .non-validato-badge {
background: rgba(231, 76, 60, 0.1) !important; background: rgba(231, 76, 60, 0.1) !important;
color: #c0392b !important; color: #c0392b !important;
border-color: #c0392b !important; border-color: #c0392b !important;
&.mio-badge {
background: rgba(var(--ion-color-secondary-rgb), 0.1) !important;
color: var(--ion-color-secondary-shade, #007bb6) !important;
border-color: var(--ion-color-secondary-shade, #007bb6) !important;
}
} }
/* ========================================================================== /* ==========================================================================
+417 -13
View File
@@ -33,8 +33,10 @@ export class HomePage implements OnDestroy {
public showOnlyMine = signal<boolean>(false); public showOnlyMine = signal<boolean>(false);
public showTopTen = signal<boolean>(false); public showTopTen = signal<boolean>(false);
public showSuggeriti = signal<boolean>(false); public showSuggeriti = signal<boolean>(false);
public showValidati = signal<boolean>(false);
public showNonValidati = signal<boolean>(false);
public isMassCardExpanded = signal<boolean>(false); public isMassCardExpanded = signal<boolean>(false);
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null); public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
public loadedThumbs = new Set<string>(); public loadedThumbs = new Set<string>();
public version = VERSION; public version = VERSION;
public appName = environment.appName; public appName = environment.appName;
@@ -44,6 +46,7 @@ export class HomePage implements OnDestroy {
public showAndroidBanner = signal<boolean>(false); public showAndroidBanner = signal<boolean>(false);
public showIosTooltip = signal<boolean>(false); public showIosTooltip = signal<boolean>(false);
public hasUpdateAvailable = signal<boolean>(false);
public fontSize = signal<number>(1.0); public fontSize = signal<number>(1.0);
public youtubePlayerService = inject(YoutubePlayerService); public youtubePlayerService = inject(YoutubePlayerService);
@@ -73,6 +76,7 @@ export class HomePage implements OnDestroy {
private swUpdate = inject(SwUpdate); private swUpdate = inject(SwUpdate);
private firstInteraction = true; private firstInteraction = true;
private updatePollInterval: any = null;
async checkForAppUpdate(event?: Event) { async checkForAppUpdate(event?: Event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
@@ -187,6 +191,24 @@ export class HomePage implements OnDestroy {
} }
} }
private async checkUpdateStatus() {
try {
if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
if (swFoundUpdate) {
this.hasUpdateAvailable.set(true);
return;
}
}
const mismatch = await this.checkVersionJson();
if (mismatch) {
this.hasUpdateAvailable.set(true);
}
} catch (err) {
console.warn('Failed to check for updates on startup:', err);
}
}
onInteraction() { onInteraction() {
if (this.firstInteraction) { if (this.firstInteraction) {
this.firstInteraction = false; this.firstInteraction = false;
@@ -241,10 +263,11 @@ export class HomePage implements OnDestroy {
const comunitaIds = this.comunitaService.comunitaCantiIds(); const comunitaIds = this.comunitaService.comunitaCantiIds();
if (comunitaCode && this.comunitaService.isFilterActive()) { if (comunitaCode && this.comunitaService.isFilterActive()) {
// Base is standard canti + my canti + community custom canti // Base is standard canti + my canti + community custom canti + remote custom canti
list = [ list = [
...this.cantiService.canti(), ...this.cantiService.canti(),
...this.myCantiService.myCanti(), ...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs(),
...this.comunitaService.comunitaCantiPersonali() ...this.comunitaService.comunitaCantiPersonali()
]; ];
@@ -278,8 +301,12 @@ export class HomePage implements OnDestroy {
return numA - numB; return numA - numB;
}); });
} else { } else {
// General context: only standard canti + my canti // General context: only standard canti + my canti + remote custom canti
list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()]; list = [
...this.cantiService.canti(),
...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs()
];
} }
// 2. Cumulative filtering // 2. Cumulative filtering
@@ -290,7 +317,7 @@ export class HomePage implements OnDestroy {
} }
// A. Filter by Playlist (if activeIds is present) // A. Filter by Playlist (if activeIds is present)
if (activeIds.length > 0 && !selectionMode) { if (activeIds.length > 0 && (!selectionMode || this.playlistService.activePlaylistId() === null)) {
list = activeIds list = activeIds
.map(id => list.find(c => c.id === id)) .map(id => list.find(c => c.id === id))
.filter((c): c is any => !!c); .filter((c): c is any => !!c);
@@ -302,6 +329,16 @@ export class HomePage implements OnDestroy {
list = list.filter(c => myIds.has(c.id)); list = list.filter(c => myIds.has(c.id));
} }
// Filter by Validati (non personali e non contrassegnati come non validati)
if (this.showValidati()) {
list = list.filter(c => !c.nonValidato && !c.id.startsWith('my_'));
}
// Filter by Non-Validati (personali o esplicitamente non validati)
if (this.showNonValidati()) {
list = list.filter(c => c.nonValidato || c.id.startsWith('my_'));
}
// C. Filter by Liturgical Moment // C. Filter by Liturgical Moment
if (litId !== null) { if (litId !== null) {
list = list.filter(c => c.id_momenti?.includes(litId)); list = list.filter(c => c.id_momenti?.includes(litId));
@@ -377,6 +414,21 @@ export class HomePage implements OnDestroy {
}); });
constructor() { constructor() {
// Check if there is an update available
this.checkUpdateStatus();
if (this.swUpdate.isEnabled) {
this.swUpdate.versionUpdates.subscribe(evt => {
if (evt.type === 'VERSION_READY') {
this.hasUpdateAvailable.set(true);
}
});
}
// Polling setup: check for updates every 30 seconds
this.updatePollInterval = setInterval(() => {
this.checkUpdateStatus();
}, 30000);
// Check if install prompts should be visible // Check if install prompts should be visible
const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true'; const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true';
const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true'; const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true';
@@ -411,6 +463,8 @@ export class HomePage implements OnDestroy {
this.showOnlyMine.set(false); this.showOnlyMine.set(false);
this.showTopTen.set(false); this.showTopTen.set(false);
this.showSuggeriti.set(false); this.showSuggeriti.set(false);
this.showValidati.set(false);
this.showNonValidati.set(false);
this.playlistService.activeListIds.set([]); this.playlistService.activeListIds.set([]);
this.playlistService.activeListName.set(null); this.playlistService.activeListName.set(null);
this.playlistService.activePlaylistId.set(null); this.playlistService.activePlaylistId.set(null);
@@ -432,6 +486,12 @@ export class HomePage implements OnDestroy {
if (params['import']) { if (params['import']) {
this.handleImport(params['import']); this.handleImport(params['import']);
} }
if (params['playlist-uid']) {
this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']);
}
if (params['restore-uid']) {
this.handleRemoteRestore(params['restore-uid']);
}
}); });
let prevSelectionMode = false; let prevSelectionMode = false;
@@ -443,7 +503,7 @@ export class HomePage implements OnDestroy {
if (selectionMode) { if (selectionMode) {
if (!prevSelectionMode) { if (!prevSelectionMode) {
if (activeIds.length === 0) { if (activeIds.length === 0 || this.playlistService.activePlaylistId() === null) {
this.isAddingSongs.set(true); this.isAddingSongs.set(true);
} else { } else {
this.isAddingSongs.set(false); this.isAddingSongs.set(false);
@@ -476,8 +536,13 @@ export class HomePage implements OnDestroy {
updatedList = [...updatedList, ...newSongs]; updatedList = [...updatedList, ...newSongs];
} }
// Previeni cicli infiniti se il contenuto della lista non è effettivamente cambiato
const currentIdsStr = currentIds.join(',');
const updatedIdsStr = updatedList.map(c => c.id).join(',');
if (currentIdsStr !== updatedIdsStr) {
this.reorderList.set(updatedList); this.reorderList.set(updatedList);
} }
}
} else { } else {
prevSelectionMode = false; prevSelectionMode = false;
this.isAddingSongs.set(false); this.isAddingSongs.set(false);
@@ -580,11 +645,200 @@ export class HomePage implements OnDestroy {
} }
} }
async handleRemotePlaylistImport(uid: string, pid?: string) {
const loading = await this.loadingCtrl.create({
message: 'Scaricamento playlist da remoto...'
});
await loading.present();
try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Risposta del server non valida: ${response.status}`);
}
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
// 1. Reconstruct custom songs
const customSongs = remoteJson
.filter((item: any) => item.momenti && !item.momenti.includes('Playlist'))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo,
testo: item.testo,
accordi: item.testo?.includes('[') ? item.testo : undefined,
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
// 2. Reconstruct playlists
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: `remote_${uid}_${item.id_canti}`,
name: `[Remote] ${item.titolo}`,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date(),
isRemote: true
};
});
if (playlists.length === 0) {
throw new Error('Nessuna playlist trovata in questa identità.');
}
// Find the selected one
let selectedPl = playlists[0];
if (pid) {
const targetId = `remote_${uid}_${pid}`;
const found = playlists.find(p => p.id === targetId || p.id.endsWith(`_${pid}`));
if (found) {
selectedPl = found;
}
}
await this.playlistService.saveRemotePlaylist(selectedPl, customSongs);
// Clear other filters to avoid confusion
this.selectedLiturgico.set(null);
this.selectedTematico.set(null);
// Select it immediately
this.selectPlaylist(selectedPl);
await loading.dismiss();
const toast = await this.toastCtrl.create({
message: `Playlist "${selectedPl.name}" caricata per consultazione!`,
duration: 3000,
color: 'success'
});
await toast.present();
} else {
throw new Error('Formato dati non valido.');
}
} catch (err: any) {
await loading.dismiss();
console.error('Failed to import remote playlist:', err);
const alert = await this.alertCtrl.create({
header: 'Errore Importazione',
message: 'Impossibile scaricare la playlist da remoto. Controlla la connessione o il codice.',
buttons: ['OK']
});
await alert.present();
} finally {
this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge' });
}
}
async handleRemoteRestore(uid: string) {
const alert = await this.alertCtrl.create({
header: 'Ripristina Identità',
message: 'Sei sicuro di voler scaricare e ripristinare i dati di questa identità? L\'ID attuale del dispositivo verrà sovrascritto.',
buttons: [
{ text: 'Annulla', role: 'cancel' },
{
text: 'Ripristina',
role: 'destructive',
handler: async () => {
const loading = await this.loadingCtrl.create({
message: 'Scaricamento dati da remoto...'
});
await loading.present();
try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (response.ok) {
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
// Reconstruct custom songs
const customSongs = remoteJson
.filter((item: any) => !item.momenti || !item.momenti.includes('Playlist'))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '',
accordi: item.testo?.includes('[') ? item.testo : undefined,
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
if (customSongs.length > 0) {
this.myCantiService.myCanti.set(customSongs);
const storage = this.cantiService.getStorage();
if (storage) {
await storage.set('my-canti', customSongs);
}
}
// Reconstruct playlists
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: String(item.id_canti),
name: item.titolo,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date()
};
});
if (playlists.length > 0) {
this.playlistService.playlists.set(playlists);
if (this.playlistService['_storage']) {
const key = this.playlistService.getPlaylistsStorageKey();
await this.playlistService['_storage'].set(key, playlists);
}
}
}
}
} catch (err) {
console.error('Failed to restore remote backup:', err);
} finally {
await loading.dismiss();
}
this.settingsService.setUserUuid(uid);
const toast = await this.toastCtrl.create({
message: 'Dati ripristinati con successo! Ricaricamento...',
duration: 2000,
color: 'success'
});
await toast.present();
setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname);
}, 1500);
}
}
]
});
await alert.present();
}
ionViewWillLeave() { ionViewWillLeave() {
} }
ngOnDestroy() { ngOnDestroy() {
this.audioEngine.stopSearchRecognition(); this.audioEngine.stopSearchRecognition();
if (this.updatePollInterval) {
clearInterval(this.updatePollInterval);
}
} }
onSearch(event: any) { onSearch(event: any) {
@@ -619,8 +873,9 @@ export class HomePage implements OnDestroy {
return [ return [
...this.cantiService.canti(), ...this.cantiService.canti(),
...this.myCantiService.myCanti(), ...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs(),
...this.comunitaService.comunitaCantiPersonali() ...this.comunitaService.comunitaCantiPersonali()
].find(c => c.id === id); ].find(c => c.id === id || String(c.id_canti) === id);
} }
getPlayingCanto() { getPlayingCanto() {
@@ -643,6 +898,7 @@ export class HomePage implements OnDestroy {
toggleOnlyMine() { toggleOnlyMine() {
this.showOnlyMine.update(v => !v); this.showOnlyMine.update(v => !v);
this.activeFilterType.set(null);
this.limit.set(10); this.limit.set(10);
} }
@@ -652,13 +908,24 @@ export class HomePage implements OnDestroy {
this.limit.set(10); this.limit.set(10);
} }
toggleIndex(id: number, type: 'liturgico' | 'tematico' | 'playlist') { toggleIndex(id: number, type: 'liturgico' | 'tematico' | 'playlist' | 'lista_completa') {
if (type === 'liturgico') { if (type === 'liturgico') {
this.selectedLiturgico.update(cur => cur === id ? null : id); this.selectedLiturgico.update(cur => cur === id ? null : id);
} else if (type === 'tematico') { } else if (type === 'tematico') {
this.selectedTematico.update(cur => cur === id ? null : id); this.selectedTematico.update(cur => cur === id ? null : id);
} }
// Mantieni aperto il menu di secondo livello se c'è una selezione attiva per quel tipo
let hasSelection = false;
if (type === 'liturgico' && this.selectedLiturgico() !== null) {
hasSelection = true;
} else if (type === 'tematico' && this.selectedTematico() !== null) {
hasSelection = true;
}
if (!hasSelection) {
this.activeFilterType.set(null); this.activeFilterType.set(null);
}
this.limit.set(10); this.limit.set(10);
} }
@@ -734,6 +1001,8 @@ export class HomePage implements OnDestroy {
this.showOnlyMine() || this.showOnlyMine() ||
this.showTopTen() || this.showTopTen() ||
this.showSuggeriti() || this.showSuggeriti() ||
this.showValidati() ||
this.showNonValidati() ||
this.playlistService.activeListName() !== null || this.playlistService.activeListName() !== null ||
this.searchQuery() !== ''; this.searchQuery() !== '';
} }
@@ -744,6 +1013,8 @@ export class HomePage implements OnDestroy {
this.showOnlyMine.set(false); this.showOnlyMine.set(false);
this.showTopTen.set(false); this.showTopTen.set(false);
this.showSuggeriti.set(false); this.showSuggeriti.set(false);
this.showValidati.set(false);
this.showNonValidati.set(false);
this.playlistService.activeListIds.set([]); this.playlistService.activeListIds.set([]);
this.playlistService.activeListName.set(null); this.playlistService.activeListName.set(null);
this.playlistService.activePlaylistId.set(null); this.playlistService.activePlaylistId.set(null);
@@ -752,9 +1023,23 @@ export class HomePage implements OnDestroy {
this.limit.set(10); this.limit.set(10);
} }
toggleFilterType(type: 'liturgico' | 'tematico' | 'playlist') { toggleFilterType(type: 'liturgico' | 'tematico' | 'playlist' | 'lista_completa') {
if (this.activeFilterType() === type) { if (this.activeFilterType() === type) {
// Chiudi solo se non ci sono selezioni di secondo livello attive per questo tipo
let hasSelection = false;
if (type === 'liturgico' && this.selectedLiturgico() !== null) {
hasSelection = true;
} else if (type === 'tematico' && this.selectedTematico() !== null) {
hasSelection = true;
} else if (type === 'playlist' && this.playlistService.activePlaylistId() !== null) {
hasSelection = true;
} else if (type === 'lista_completa' && (this.showValidati() || this.showNonValidati())) {
hasSelection = true;
}
if (!hasSelection) {
this.activeFilterType.set(null); this.activeFilterType.set(null);
}
} else { } else {
this.activeFilterType.set(type); this.activeFilterType.set(type);
} }
@@ -764,12 +1049,13 @@ export class HomePage implements OnDestroy {
this.playlistService.activeListIds.set(pl.ids); this.playlistService.activeListIds.set(pl.ids);
this.playlistService.activeListName.set(pl.name); this.playlistService.activeListName.set(pl.name);
this.playlistService.activePlaylistId.set(pl.id); this.playlistService.activePlaylistId.set(pl.id);
this.activeFilterType.set(null); // Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva
this.limit.set(50); this.limit.set(50);
} }
toggleTopTen() { toggleTopTen() {
this.showTopTen.update(v => !v); this.showTopTen.update(v => !v);
this.activeFilterType.set(null);
this.limit.set(30); this.limit.set(30);
} }
@@ -779,8 +1065,53 @@ export class HomePage implements OnDestroy {
this.limit.set(30); this.limit.set(30);
} }
toggleValidati() {
this.showValidati.update(v => !v);
this.showNonValidati.set(false);
if (!this.showValidati()) {
this.activeFilterType.set(null);
} else {
this.activeFilterType.set('lista_completa');
}
this.limit.set(30);
}
clearValidati(event?: Event) {
if (event) event.stopPropagation();
this.showValidati.set(false);
this.activeFilterType.set(null);
this.limit.set(30);
}
toggleNonValidati() {
this.showNonValidati.update(v => !v);
this.showValidati.set(false);
if (!this.showNonValidati()) {
this.activeFilterType.set(null);
} else {
this.activeFilterType.set('lista_completa');
}
this.limit.set(30);
}
clearNonValidati(event?: Event) {
if (event) event.stopPropagation();
this.showNonValidati.set(false);
this.activeFilterType.set(null);
this.limit.set(30);
}
clearListaCompleta(event?: Event) {
if (event) event.stopPropagation();
this.showValidati.set(false);
this.showNonValidati.set(false);
this.activeFilterType.set(null);
this.limit.set(10);
}
toggleSuggeriti() { toggleSuggeriti() {
this.showSuggeriti.update(v => !v); this.showSuggeriti.update(v => !v);
this.activeFilterType.set(null);
this.limit.set(30); this.limit.set(30);
} }
@@ -996,6 +1327,42 @@ export class HomePage implements OnDestroy {
handleScannedData(data: string) { handleScannedData(data: string) {
if (!data) return; if (!data) return;
if (data.includes('playlist-uid=')) {
try {
const urlObj = new URL(data);
const uid = urlObj.searchParams.get('playlist-uid');
const pid = urlObj.searchParams.get('playlist-id');
if (uid) {
this.handleRemotePlaylistImport(uid, pid || undefined);
return;
}
} catch (e) {
const uidMatch = data.match(/[?&]playlist-uid=([^&]+)/);
const pidMatch = data.match(/[?&]playlist-id=([^&]+)/);
if (uidMatch && uidMatch[1]) {
this.handleRemotePlaylistImport(uidMatch[1], pidMatch ? pidMatch[1] : undefined);
return;
}
}
}
if (data.includes('restore-uid=')) {
try {
const urlObj = new URL(data);
const uid = urlObj.searchParams.get('restore-uid');
if (uid) {
this.handleRemoteRestore(uid);
return;
}
} catch (e) {
const uidMatch = data.match(/[?&]restore-uid=([^&]+)/);
if (uidMatch && uidMatch[1]) {
this.handleRemoteRestore(uidMatch[1]);
return;
}
}
}
if (data.includes('import=')) { if (data.includes('import=')) {
try { try {
let base64 = ''; let base64 = '';
@@ -1115,6 +1482,11 @@ export class HomePage implements OnDestroy {
return !!id && id.startsWith('comunita_'); return !!id && id.startsWith('comunita_');
} }
isRemotePlaylist(): boolean {
const id = this.playlistService.activePlaylistId();
return !!id && id.startsWith('remote_');
}
isActivePlaylistSaved(): boolean { isActivePlaylistSaved(): boolean {
const id = this.playlistService.activePlaylistId(); const id = this.playlistService.activePlaylistId();
if (!id) return false; if (!id) return false;
@@ -1164,16 +1536,22 @@ export class HomePage implements OnDestroy {
const name = this.playlistService.activeListName(); const name = this.playlistService.activeListName();
if (!id || !name) return; if (!id || !name) return;
const isRemote = this.isRemotePlaylist();
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Elimina Playlist', header: isRemote ? 'Rimuovi Playlist' : 'Elimina Playlist',
message: `Vuoi davvero eliminare "${name}"?`, message: isRemote ? `Vuoi davvero rimuovere la playlist "${name}"?` : `Vuoi davvero eliminare "${name}"?`,
buttons: [ buttons: [
{ text: 'Annulla', role: 'cancel' }, { text: 'Annulla', role: 'cancel' },
{ {
text: 'Elimina', text: isRemote ? 'Rimuovi' : 'Elimina',
role: 'destructive', role: 'destructive',
handler: () => { handler: () => {
if (isRemote) {
this.playlistService.clearRemotePlaylist();
} else {
this.playlistService.deletePlaylist(id); this.playlistService.deletePlaylist(id);
}
this.clearSpecialList(); this.clearSpecialList();
} }
} }
@@ -1203,6 +1581,31 @@ export class HomePage implements OnDestroy {
this.playlistService.activeListName.set(name); this.playlistService.activeListName.set(name);
} }
toggleSongSelection(id: string) {
if (!this.playlistService.selectionMode()) {
const activeIds = this.playlistService.activeListIds();
const activeName = this.playlistService.activeListName();
if (activeIds.length > 0) {
// Se selezioniamo i numerini di una playlist attiva, non andiamo in modifica di quella playlist
// ma avviamo la selezione partendo da questa lista per creare una nuova playlist.
this.playlistService.activePlaylistId.set(null);
const clickedCanto = this.findCanto(id);
if (clickedCanto) {
this.playlistService.selectedIds.set(new Set([clickedCanto.id]));
this.reorderList.set([clickedCanto]);
}
this.playlistService.selectionMode.set(true);
this.isAddingSongs.set(true); // Rimaniamo sulla lista filtrata per selezionare altri canti
return;
}
}
this.playlistService.toggleSongSelection(id);
}
drop(event: CdkDragDrop<any[]>) { drop(event: CdkDragDrop<any[]>) {
const arr = [...this.reorderList()]; const arr = [...this.reorderList()];
moveItemInArray(arr, event.previousIndex, event.currentIndex); moveItemInArray(arr, event.previousIndex, event.currentIndex);
@@ -1233,6 +1636,7 @@ export class HomePage implements OnDestroy {
} else { } else {
this.comunitaService.isFilterActive.update(v => !v); this.comunitaService.isFilterActive.update(v => !v);
} }
this.activeFilterType.set(null);
} }
editComunitaCode(event: Event) { editComunitaCode(event: Event) {
@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable()
export class ApiAuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.url.includes('api.canticristiani.it')) {
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
const authReq = req.clone({
setHeaders: {
Authorization: authHeader
}
});
return next.handle(authReq);
}
return next.handle(req);
}
}
+10 -10
View File
@@ -156,9 +156,16 @@
</div> </div>
<!-- Camera Head Gestures Toggle in Portrait --> <!-- Camera Head Gestures Toggle in Portrait -->
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" *ngIf="settingsService.enableVisualAutoscroll()"> <div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon> <ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button> </ion-button>
<div class="camera-indicator" *ngIf="enableCameraNavigation()" [class.tilted]="faceDetector.isTilted()">
<ion-icon name="person-outline"
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
</div>
</div>
<!-- Transposition (Only in chords mode) --> <!-- Transposition (Only in chords mode) -->
@@ -208,12 +215,5 @@
</ion-toolbar> </ion-toolbar>
</ion-footer> </ion-footer>
<!-- Floating Camera Preview for Head Navigation --> <!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
<div class="camera-preview-floating glass" *ngIf="enableCameraNavigation()"> <video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
<video id="face-preview-video" muted playsinline autoplay></video>
<div class="camera-preview-overlay">
<div class="angle-indicator" [class.tilted]="faceDetector.isTilted()">
{{ faceDetector.currentTiltAngle() }}°
</div>
</div>
</div>
+37 -17
View File
@@ -257,6 +257,28 @@
ion-icon { font-size: 0.9rem; } ion-icon { font-size: 0.9rem; }
} }
.camera-indicator {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.8rem;
font-weight: 700;
color: var(--ion-color-secondary);
padding: 0 4px;
transition: color 0.15s ease-out;
ion-icon {
font-size: 1.1rem;
transition: transform 0.15s ease-out;
display: inline-block;
}
&.tilted {
color: var(--ion-color-success, #2ed573);
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
}
}
} }
// Transcript area // Transcript area
@@ -741,16 +763,17 @@ ion-content.full-screen-content {
position: fixed; position: fixed;
bottom: 60px; bottom: 60px;
right: 15px; right: 15px;
width: 90px; padding: 8px 14px;
height: 120px; border-radius: 20px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.4); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000; z-index: 1000;
display: flex; display: flex;
flex-direction: column; align-items: center;
background: #000; justify-content: center;
background: rgba(18, 18, 18, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
@media (orientation: landscape) { @media (orientation: landscape) {
bottom: 15px; bottom: 15px;
@@ -758,27 +781,24 @@ ion-content.full-screen-content {
} }
video { video {
width: 100%; position: absolute;
height: 100%; width: 1px;
object-fit: cover; height: 1px;
transform: scaleX(-1); // Mirror camera preview opacity: 0;
pointer-events: none;
} }
.camera-preview-overlay { .camera-preview-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
padding: 2px 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
.angle-indicator { .angle-indicator {
font-size: 0.75rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
display: flex;
align-items: center;
&.tilted { &.tilted {
color: #2ed573; color: #2ed573;
+59 -39
View File
@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AlertController, ToastController, GestureController } from '@ionic/angular'; import { AlertController, ToastController, GestureController } from '@ionic/angular';
@@ -177,19 +177,40 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (!found) { if (!found) {
found = myCantiList.find(c => c.id === id); found = myCantiList.find(c => c.id === id);
} }
if (!found) {
found = this.playlistService.remoteCustomSongs().find(c => c.id === id || String(c.id_canti) === id);
}
if (!found) { if (!found) {
found = comunitaCantiPers.find(c => c.id === id); found = comunitaCantiPers.find(c => c.id === id);
} }
if (found) { if (found) {
const current = this.canto(); const current = untracked(() => this.canto());
// Avoid duplicate triggers for the same song // Avoid duplicate triggers for the same song, but update if song content changed
if (!current || current.id !== found.id) { const contentChanged = !current ||
current.id !== found.id ||
current.titolo !== found.titolo ||
current.autore !== found.autore ||
current.link_youtube !== found.link_youtube ||
current.testo !== found.testo ||
current.accordi !== found.accordi ||
JSON.stringify(current.id_momenti) !== JSON.stringify(found.id_momenti);
if (contentChanged) {
this.logPreviousSongTime(); this.logPreviousSongTime();
this.canto.set(found); this.canto.set(found);
this.currentLineIndex.set(0);
this.lastScrollBlock.set('start');
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 });
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
setTimeout(() => {
const scrollEl = this.el.nativeElement.querySelector('.lyrics-container');
if (scrollEl) {
scrollEl.scrollTop = 0;
}
}, 100);
} }
} }
} }
@@ -203,15 +224,27 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const activePlaylistId = this.playlistService.activePlaylistId(); const activePlaylistId = this.playlistService.activePlaylistId();
let playlistSongSetting: any = null; let playlistSongSetting: any = null;
if (activePlaylistId) { if (activePlaylistId) {
if (activePlaylistId.startsWith('remote_')) {
const pl = this.playlistService.remotePlaylist();
if (pl && pl.songSettings && pl.songSettings[c.id]) {
playlistSongSetting = pl.songSettings[c.id];
}
} else {
const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId); const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId);
if (pl && pl.songSettings && pl.songSettings[c.id]) { if (pl && pl.songSettings && pl.songSettings[c.id]) {
playlistSongSetting = pl.songSettings[c.id]; playlistSongSetting = pl.songSettings[c.id];
} }
} }
}
if (playlistSongSetting) { if (playlistSongSetting) {
this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0); this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0);
this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2); this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2);
if (playlistSongSetting.zoom !== undefined) {
this.fontSize.set(playlistSongSetting.zoom);
} else {
this.fontSize.set(1.0);
}
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) { } 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 === c.id_canti || s.id_canti === Number(c.id)); const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id));
@@ -322,6 +355,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
onTouchEnd() { onTouchEnd() {
this.initialPinchDistance = null; this.initialPinchDistance = null;
this.updatePlaylistSettings();
} }
private getDistance(t1: Touch, t2: Touch): number { private getDistance(t1: Touch, t2: Touch): number {
@@ -334,9 +368,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.transposeAmount.set(0); this.transposeAmount.set(0);
} }
transposeUp() { private updatePlaylistSettings() {
this.transposeAmount.update(v => v + 1);
const c = this.canto(); const c = this.canto();
if (c) { if (c) {
const dbSpeed = this.autoscrollSpeed() * 100; const dbSpeed = this.autoscrollSpeed() * 100;
@@ -344,30 +376,35 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const activePlaylistId = this.playlistService.activePlaylistId(); const activePlaylistId = this.playlistService.activePlaylistId();
if (activePlaylistId) { if (activePlaylistId) {
this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed()); if (activePlaylistId.startsWith('remote_')) {
return;
}
this.playlistService.updatePlaylistSongSettings(
activePlaylistId,
c.id,
this.transposeAmount(),
this.autoscrollSpeed(),
this.fontSize()
);
} }
} }
} }
transposeUp() {
this.transposeAmount.update(v => v + 1);
this.updatePlaylistSettings();
}
transposeDown() { transposeDown() {
this.transposeAmount.update(v => v - 1); this.transposeAmount.update(v => v - 1);
this.updatePlaylistSettings();
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());
}
}
} }
zoomIn() { zoomIn() {
if (this.fontSize() < this.MAX_FONT) { if (this.fontSize() < this.MAX_FONT) {
this.fontSize.update(v => Math.min(v + this.FONT_STEP, this.MAX_FONT)); this.fontSize.update(v => Math.min(v + this.FONT_STEP, this.MAX_FONT));
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings();
} }
} }
@@ -375,6 +412,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (this.fontSize() > this.MIN_FONT) { if (this.fontSize() > this.MIN_FONT) {
this.fontSize.update(v => Math.max(v - this.FONT_STEP, this.MIN_FONT)); this.fontSize.update(v => Math.max(v - this.FONT_STEP, this.MIN_FONT));
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings();
} }
} }
@@ -857,30 +895,12 @@ 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(); this.updatePlaylistSettings();
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(); this.updatePlaylistSettings();
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() {
@@ -5,8 +5,9 @@ import { IonicModule, ToastController, IonTextarea, PopoverController, NavContro
import { createWorker } from 'tesseract.js'; import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service'; import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service'; import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service'; import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule, Router } from '@angular/router';
@Component({ @Component({
selector: 'app-propose-canto', selector: 'app-propose-canto',
@@ -21,9 +22,11 @@ export class ProposeCantoPage implements OnInit {
public cantiService = inject(CantiService); public cantiService = inject(CantiService);
private myCantiService = inject(MyCantiService); private myCantiService = inject(MyCantiService);
private playlistService = inject(PlaylistService);
private navCtrl = inject(NavController); private navCtrl = inject(NavController);
public themeService = inject(ThemeService); public themeService = inject(ThemeService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router);
title: string = ''; title: string = '';
author: string = ''; author: string = '';
@@ -112,10 +115,11 @@ export class ProposeCantoPage implements OnInit {
const editId = params['editId']; const editId = params['editId'];
if (editId) { if (editId) {
this.editId = editId; this.editId = editId;
// Find the song in standard canti or personal canti list // Find the song in standard canti, personal canti, or remote custom canti list
const song = [ const song = [
...this.cantiService.canti(), ...this.cantiService.canti(),
...this.myCantiService.myCanti() ...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs()
].find(c => c.id === editId); ].find(c => c.id === editId);
if (song) { if (song) {
@@ -128,8 +132,8 @@ export class ProposeCantoPage implements OnInit {
const litIds = this.cantiService.indiceLiturgico().map(m => m.id); const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
const temIds = this.cantiService.indiceTematico().map(m => m.id); const temIds = this.cantiService.indiceTematico().map(m => m.id);
this.selectedLiturgico = song.id_momenti?.filter(id => litIds.includes(id)) || []; this.selectedLiturgico = song.id_momenti?.filter((id: number) => litIds.includes(id)) || [];
this.selectedTematico = song.id_momenti?.filter(id => temIds.includes(id)) || []; this.selectedTematico = song.id_momenti?.filter((id: number) => temIds.includes(id)) || [];
} }
} }
}); });
@@ -749,7 +753,44 @@ export class ProposeCantoPage implements OnInit {
// Combine lit and tematico for id_momenti // Combine lit and tematico for id_momenti
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico]; const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
await this.myCantiService.saveCanto({ const activePlaylistId = this.playlistService.activePlaylistId();
const isRemotePlaylist = activePlaylistId && activePlaylistId.startsWith('remote_');
if (isRemotePlaylist) {
// 1. Clone the song (generate a brand new my_... ID)
const savedCanto = await this.myCantiService.saveCanto({
titolo: this.title,
autore: this.author,
link_youtube: this.youtubeLink,
testo: this.content,
accordi: this.content,
id_momenti: id_momenti
});
// 2. Clone/convert remote playlist to local personal playlist
const remotePl = this.playlistService.remotePlaylist();
if (remotePl) {
const originalIds = remotePl.ids || [];
const updatedIds = originalIds.map((id: string) => id === this.editId ? savedCanto.id : id);
const songSettings = { ...(remotePl.songSettings || {}) };
if (this.editId && songSettings[this.editId]) {
songSettings[savedCanto.id] = { ...songSettings[this.editId] };
delete songSettings[this.editId];
}
const localName = remotePl.name.replace('[Remote] ', '');
// Force save as a new local playlist
this.playlistService.activePlaylistId.set(null);
await this.playlistService.savePlaylist(localName, updatedIds, songSettings);
}
// Navigate to the player with the new cloned song ID immediately
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else {
// Standard path
const savedCanto = await this.myCantiService.saveCanto({
id: this.editId || undefined, id: this.editId || undefined,
titolo: this.title, titolo: this.title,
autore: this.author, autore: this.author,
@@ -759,6 +800,15 @@ export class ProposeCantoPage implements OnInit {
id_momenti: id_momenti id_momenti: id_momenti
}); });
if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) {
await this.playlistService.replaceSongIdInPlaylists(this.editId, savedCanto.id);
}
if (this.editId && savedCanto && savedCanto.id && this.editId !== savedCanto.id) {
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else {
this.navCtrl.back(); this.navCtrl.back();
} }
} }
}
}
+41
View File
@@ -156,6 +156,47 @@
</ion-item> </ion-item>
</div> </div>
<!-- Identità Utente e Ripristino -->
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.comunitaEnabled() || settingsService.showEditor()">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title" style="color: var(--ion-color-secondary); font-weight: bold; font-size: 1.05rem;">
Identità Utente e Ripristino
</h2>
</div>
<div class="ion-padding-horizontal ion-padding-bottom">
<p class="settings-item-subtitle outfit-font" style="margin: 0 0 16px 0; font-size: 0.85rem; line-height: 1.45; opacity: 0.85; color: var(--ion-text-color);">
Questo codice univoco identifica in modo anonimo il tuo dispositivo e ti consente di gestire comunità e canti. Salva il codice o il QR code per ripristinare il tuo profilo su un nuovo dispositivo.
</p>
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Codice ID</div>
<div style="font-family: monospace; font-size: 0.78rem; opacity: 0.9; color: var(--ion-text-color);" class="select-all">{{ settingsService.userUuid() }}</div>
</div>
<ion-button fill="clear" size="small" (click)="showBackupQrCode()" color="secondary" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
<ion-icon name="qr-code-outline" slot="icon-only" style="font-size: 1.35rem;"></ion-icon>
</ion-button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<ion-button expand="block" fill="outline" color="secondary" (click)="scanRestoreQrCode()" class="outfit-font" style="--border-radius: 10px; font-size: 0.8rem; font-weight: bold; text-transform: none; margin: 0; height: 38px;">
<ion-icon name="scan-outline" slot="start"></ion-icon>
Scansiona QR
</ion-button>
<ion-button expand="block" fill="outline" color="secondary" (click)="manualRestore()" class="outfit-font" style="--border-radius: 10px; font-size: 0.8rem; font-weight: bold; text-transform: none; margin: 0; height: 38px;">
<ion-icon name="keypad-outline" slot="start"></ion-icon>
Inserisci Codice
</ion-button>
</div>
<ion-button expand="block" fill="solid" color="secondary" (click)="syncLocalDataToServer()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
<ion-icon name="cloud-upload-outline" slot="start"></ion-icon>
Salva Backup sul Server
</ion-button>
</div>
</div>
<!-- Invio Dati Statistici --> <!-- Invio Dati Statistici -->
<div class="settings-group glass ion-margin-bottom"> <div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
+207 -1
View File
@@ -4,7 +4,7 @@ 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, VersionReadyEvent } from '@angular/service-worker'; import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { ToastController, ModalController, AlertController } from '@ionic/angular'; import { ToastController, ModalController, AlertController, LoadingController } 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';
@@ -15,6 +15,8 @@ import { CantiLettureService } from '../../services/canti-letture.service';
import { ComunitaService } from '../../services/comunita.service'; import { ComunitaService } from '../../services/comunita.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { showFullscreenUpdateOverlay } from '../../app.component'; import { showFullscreenUpdateOverlay } from '../../app.component';
import { QrScannerComponent } from '../../components/qr-scanner/qr-scanner.component';
import { IdentityQrModalComponent } from '../../components/identity-qr-modal/identity-qr-modal.component';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@@ -35,6 +37,7 @@ export class SettingsPage {
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
private modalCtrl = inject(ModalController); private modalCtrl = inject(ModalController);
private alertCtrl = inject(AlertController); private alertCtrl = inject(AlertController);
private loadingCtrl = inject(LoadingController);
private router = inject(Router); private router = inject(Router);
public version = VERSION; public version = VERSION;
@@ -52,6 +55,209 @@ export class SettingsPage {
this.showIosInstructions = !this.showIosInstructions; this.showIosInstructions = !this.showIosInstructions;
} }
async showBackupQrCode() {
const modal = await this.modalCtrl.create({
component: IdentityQrModalComponent,
componentProps: {
userUuid: this.settingsService.userUuid()
}
});
return await modal.present();
}
async scanRestoreQrCode() {
const modal = await this.modalCtrl.create({
component: QrScannerComponent
});
await modal.present();
const { data } = await modal.onWillDismiss();
if (data) {
this.confirmRestore(data);
}
}
async manualRestore() {
const alert = await this.alertCtrl.create({
header: 'Ripristina con Codice',
message: 'Digita o incolla il tuo codice identificativo univoco.',
inputs: [
{
name: 'code',
type: 'text',
placeholder: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx',
value: ''
}
],
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Conferma',
handler: (data) => {
if (data.code && data.code.trim()) {
this.confirmRestore(data.code.trim());
}
}
}
]
});
await alert.present();
}
private async confirmRestore(code: string) {
// Basic validation for UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(code)) {
const errorAlert = await this.alertCtrl.create({
header: 'Codice Non Valido',
message: 'Il codice inserito non sembra essere un identificativo univoco valido. Riprova.',
buttons: ['OK']
});
await errorAlert.present();
return;
}
const alert = await this.alertCtrl.create({
header: 'Ripristina Identità',
message: 'Sei sicuro di voler ripristinare questa identità? L\'ID attuale del dispositivo verrà sovrascritto permanentemente ed eventuali canti e playlist remoti associati al nuovo ID verranno scaricati.',
buttons: [
{
text: 'Annulla',
role: 'cancel'
},
{
text: 'Ripristina',
role: 'destructive',
handler: async () => {
const loading = await this.loadingCtrl.create({
message: 'Scaricamento dati da remoto...'
});
await loading.present();
try {
const response = await fetch(`https://api.canticristiani.it/${code}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (response.ok) {
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
// Reconstruct custom songs
const customSongs = remoteJson
.filter((item: any) => !item.momenti || !item.momenti.includes('Playlist'))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '',
accordi: item.testo?.includes('[') ? item.testo : undefined,
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
if (customSongs.length > 0) {
this.myCantiService.myCanti.set(customSongs);
const storage = this.cantiService.getStorage();
if (storage) {
await storage.set('my-canti', customSongs);
}
}
// Reconstruct playlists
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: String(item.id_canti),
name: item.titolo,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date()
};
});
if (playlists.length > 0) {
this.playlistService.playlists.set(playlists);
if (this.playlistService['_storage']) {
const key = this.playlistService.getPlaylistsStorageKey();
await this.playlistService['_storage'].set(key, playlists);
}
}
}
}
} catch (err) {
console.error('Failed to restore backup data:', err);
} finally {
await loading.dismiss();
}
this.settingsService.setUserUuid(code);
const toast = await this.toastCtrl.create({
message: 'Identità e dati ripristinati con successo! Ricaricamento...',
duration: 2000,
color: 'success',
position: 'bottom'
});
await toast.present();
setTimeout(() => {
window.location.replace(window.location.origin + window.location.pathname);
}, 1500);
}
}
]
});
await alert.present();
}
async syncLocalDataToServer() {
const uid = this.settingsService.userUuid();
if (!uid) {
const alert = await this.alertCtrl.create({
header: 'Errore',
message: 'Nessun identificativo utente (UID) trovato.',
buttons: ['OK']
});
await alert.present();
return;
}
const toastLoading = await this.toastCtrl.create({
message: 'Invio backup in corso...',
duration: 1500,
color: 'secondary'
});
await toastLoading.present();
try {
await this.playlistService.syncLocalDataToServer();
await toastLoading.dismiss();
const toastSuccess = await this.toastCtrl.create({
message: 'Backup sincronizzato con successo!',
duration: 3000,
color: 'success'
});
await toastSuccess.present();
} catch (err: any) {
try {
await toastLoading.dismiss();
} catch (e) {}
console.error('Failed to backup to server:', err);
const alertError = await this.alertCtrl.create({
header: 'Errore di sincronizzazione',
message: 'Impossibile inviare il backup al server. Controlla la connessione e riprova.',
buttons: ['OK']
});
await alertError.present();
}
}
onModeChange(event: any) { onModeChange(event: any) {
this.settingsService.setShowChordsDefault(event.detail.value === 'chords'); this.settingsService.setShowChordsDefault(event.detail.value === 'chords');
} }
+1 -1
View File
@@ -45,7 +45,7 @@ export class CantiLettureService {
public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso
public suggestionsMomentsMap = signal<Map<number, string[]>>(new Map()); // id_canto -> moments[] public suggestionsMomentsMap = signal<Map<number, string[]>>(new Map()); // id_canto -> moments[]
private JSON_URL = 'http://185.193.67.105:3000/cantiletture.json'; private JSON_URL = 'https://api.canticristiani.it/cantiletture.json';
private SECURE_JSON_URL = '/api/cantiletture.json'; private SECURE_JSON_URL = '/api/cantiletture.json';
constructor() { constructor() {
+6
View File
@@ -44,6 +44,7 @@ export class CantiService {
public momenti = signal<Indice[]>([]); public momenti = signal<Indice[]>([]);
public loading = signal<boolean>(false); public loading = signal<boolean>(false);
public progress = signal<number>(0); public progress = signal<number>(0);
public firstLoadCompleted = signal<boolean>(false);
private API_URL = 'https://www.canticristiani.it/api/canti.json'; private API_URL = 'https://www.canticristiani.it/api/canti.json';
@@ -55,6 +56,9 @@ export class CantiService {
const storage = await this.storage.create(); const storage = await this.storage.create();
this._storage = storage; this._storage = storage;
await this.loadFromStorage(); await this.loadFromStorage();
if (this.canti() && this.canti().length > 0) {
this.firstLoadCompleted.set(true);
}
this.refresh(); this.refresh();
} }
@@ -132,11 +136,13 @@ export class CantiService {
} }
this.progress.set(100); this.progress.set(100);
this.loading.set(false); this.loading.set(false);
this.firstLoadCompleted.set(true);
} }
}, },
error: (error) => { error: (error) => {
console.error('Failed to fetch canti', error); console.error('Failed to fetch canti', error);
this.loading.set(false); this.loading.set(false);
this.firstLoadCompleted.set(true);
} }
}); });
} }
+19 -2
View File
@@ -1,8 +1,9 @@
import { Injectable, signal, inject } from '@angular/core'; import { Injectable, signal, inject, Injector } from '@angular/core';
import { Storage } from '@ionic/storage-angular'; import { Storage } from '@ionic/storage-angular';
import { Canto, CantiService } from './canti.service'; import { Canto, CantiService } from './canti.service';
import { ToastController } from '@ionic/angular'; import { ToastController } from '@ionic/angular';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { PlaylistService } from './playlist.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -11,6 +12,8 @@ export class MyCantiService {
private storage = inject(Storage); private storage = inject(Storage);
private cantiService = inject(CantiService); private cantiService = inject(CantiService);
private toastController = inject(ToastController); private toastController = inject(ToastController);
private injector = inject(Injector);
private playlistService!: PlaylistService;
private _storage: Storage | null = null; private _storage: Storage | null = null;
public myCanti = signal<Canto[]>([]); public myCanti = signal<Canto[]>([]);
@@ -56,9 +59,11 @@ export class MyCantiService {
}); });
// Fallback if not found in list (should not happen normally) // Fallback if not found in list (should not happen normally)
if (!updated.some(c => c.id === canto.id)) { if (!updated.some(c => c.id === canto.id)) {
const numericId = canto.id ? Number(canto.id.replace('my_', '')) : NaN;
const idCanti = isNaN(numericId) ? Date.now() : numericId;
targetCanto = { targetCanto = {
id: canto.id, id: canto.id,
id_canti: canto.id_canti || Date.now(), id_canti: canto.id_canti || idCanti,
titolo: canto.titolo || 'Senza Titolo', titolo: canto.titolo || 'Senza Titolo',
testo: canto.testo || '', testo: canto.testo || '',
accordi: canto.accordi, accordi: canto.accordi,
@@ -86,6 +91,12 @@ export class MyCantiService {
this.myCanti.set(updated); this.myCanti.set(updated);
await this._storage?.set('my-canti', updated); await this._storage?.set('my-canti', updated);
// Sincronizza automaticamente in background
if (!this.playlistService) {
this.playlistService = this.injector.get(PlaylistService);
}
this.playlistService.syncLocalDataToServer();
const toast = await this.toastController.create({ const toast = await this.toastController.create({
message: 'Canto salvato nei "Miei Canti"!', message: 'Canto salvato nei "Miei Canti"!',
duration: 2000, duration: 2000,
@@ -100,6 +111,12 @@ export class MyCantiService {
const updated = this.myCanti().filter(c => c.id !== id); const updated = this.myCanti().filter(c => c.id !== id);
this.myCanti.set(updated); this.myCanti.set(updated);
await this._storage?.set('my-canti', updated); await this._storage?.set('my-canti', updated);
// Sincronizza automaticamente in background
if (!this.playlistService) {
this.playlistService = this.injector.get(PlaylistService);
}
this.playlistService.syncLocalDataToServer();
} }
async sendAllMyCanti() { async sendAllMyCanti() {
+264 -10
View File
@@ -1,9 +1,11 @@
import { Injectable, signal, inject, computed, effect } from '@angular/core'; import { Injectable, signal, inject, computed, effect, Injector } from '@angular/core';
import { Storage } from '@ionic/storage-angular'; 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'; import { ToastController, AlertController } from '@ionic/angular';
import { SettingsService } from './settings.service';
import { MyCantiService } from './my-canti.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -13,6 +15,10 @@ export class PlaylistService {
private cantiService = inject(CantiService); private cantiService = inject(CantiService);
private comunitaService = inject(ComunitaService); private comunitaService = inject(ComunitaService);
private toastCtrl = inject(ToastController); private toastCtrl = inject(ToastController);
private alertCtrl = inject(AlertController);
private settingsService = inject(SettingsService);
private injector = inject(Injector);
private myCantiService!: MyCantiService;
public selectionMode = signal<boolean>(false); public selectionMode = signal<boolean>(false);
public selectedIds = signal<Set<string>>(new Set()); public selectedIds = signal<Set<string>>(new Set());
@@ -24,6 +30,9 @@ export class PlaylistService {
public activeListName = signal<string | null>(null); public activeListName = signal<string | null>(null);
public activePlaylistId = signal<string | null>(null); public activePlaylistId = signal<string | null>(null);
public remotePlaylist = signal<any | null>(null);
public remoteCustomSongs = signal<any[]>([]);
private _storage: Storage | null = null; private _storage: Storage | null = null;
private initPromise!: Promise<void>; private initPromise!: Promise<void>;
@@ -41,14 +50,20 @@ export class PlaylistService {
})); }));
}); });
// Merged list: personal playlists + community scalette // Merged list: personal playlists + community scalette + remote playlist
public allPlaylists = computed(() => { public allPlaylists = computed(() => {
const community = this.comunitaPlaylists(); const community = this.comunitaPlaylists();
const personal = this.playlists(); const personal = this.playlists();
const remote = this.remotePlaylist();
let list = [...personal.map(p => ({ ...p, isComunita: false, isRemote: false }))];
if (community.length > 0) { if (community.length > 0) {
return [...community, ...personal.map(p => ({ ...p, isComunita: false }))]; list = [...community, ...list];
} }
return personal.map(p => ({ ...p, isComunita: false })); if (remote) {
list = [{ ...remote, isComunita: false, isRemote: true }, ...list];
}
return list;
}); });
constructor() { constructor() {
@@ -65,7 +80,7 @@ export class PlaylistService {
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
} }
private getPlaylistsStorageKey(): string { getPlaylistsStorageKey(): string {
const code = this.comunitaService.comunitaCode(); const code = this.comunitaService.comunitaCode();
const isCommunityActive = this.comunitaService.isFilterActive(); const isCommunityActive = this.comunitaService.isFilterActive();
if (code && isCommunityActive) { if (code && isCommunityActive) {
@@ -89,6 +104,78 @@ export class PlaylistService {
const lastKey = `lastPlaylist_${key}`; const lastKey = `lastPlaylist_${key}`;
const last = await this._storage.get(lastKey); const last = await this._storage.get(lastKey);
this.lastPlaylist.set(last || null); this.lastPlaylist.set(last || null);
// Carica la playlist remota persistita e i relativi canti personalizzati
const remotePl = await this._storage.get('remote_playlist');
if (remotePl) {
this.remotePlaylist.set(remotePl);
}
const remoteSongs = await this._storage.get('remote_custom_songs');
if (remoteSongs) {
this.remoteCustomSongs.set(remoteSongs);
}
// Aggiorna in background la playlist remota per sincronizzare eventuali modifiche
if (remotePl) {
this.refreshRemotePlaylist();
}
}
async refreshRemotePlaylist() {
const remotePl = this.remotePlaylist();
if (!remotePl) return;
const parts = remotePl.id.split('_');
if (parts.length < 3) return;
const uid = parts[1];
try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) return;
const remoteJson = await response.json();
if (Array.isArray(remoteJson)) {
const customSongs = remoteJson
.filter((item: any) => !item.momenti || !item.momenti.includes('Playlist'))
.map((item: any) => ({
id: `my_${item.id_canti}`,
id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '',
accordi: item.testo?.includes('[') ? item.testo : undefined,
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
}));
const playlists = remoteJson
.filter((item: any) => item.momenti && item.momenti.includes('Playlist'))
.map((item: any) => {
let songSettings = {};
if (item.periodi && item.periodi.length > 0) {
try {
songSettings = JSON.parse(item.periodi[0]);
} catch (e) {}
}
return {
id: `remote_${uid}_${item.id_canti}`,
name: `[Remote] ${item.titolo}`,
ids: item.testo ? item.testo.split(',') : [],
songSettings: songSettings,
createdAt: new Date(),
isRemote: true
};
});
const updatedPl = playlists.find(p => p.id === remotePl.id);
if (updatedPl) {
this.remotePlaylist.set(updatedPl);
this.remoteCustomSongs.set(customSongs);
await this._storage?.set('remote_playlist', updatedPl);
await this._storage?.set('remote_custom_songs', customSongs);
console.log('[Remote-Sync] Remote playlist and custom songs updated successfully.');
}
}
} catch (err) {
console.warn('[Remote-Sync] Failed to refresh remote playlist:', err);
}
} }
toggleSelectionMode() { toggleSelectionMode() {
@@ -154,14 +241,46 @@ export class PlaylistService {
this.selectedIds.set(new Set()); this.selectedIds.set(new Set());
this.selectionMode.set(false); this.selectionMode.set(false);
this.activePlaylistId.set(newPlaylist.id); this.activePlaylistId.set(newPlaylist.id);
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
} }
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number) { async replaceSongIdInPlaylists(oldId: string, newId: string) {
await this.initPromise;
this.playlists.update(p => p.map(pl => {
if (pl.ids && pl.ids.includes(oldId)) {
const updatedIds = pl.ids.map((id: string) => id === oldId ? newId : id);
// Copia anche i parametri di tonalità/zoom/velocità della canzone se presenti
const songSettings = { ...(pl.songSettings || {}) };
if (songSettings[oldId]) {
songSettings[newId] = { ...songSettings[oldId] };
delete songSettings[oldId];
}
return { ...pl, ids: updatedIds, songSettings };
}
return pl;
}));
const key = this.getPlaylistsStorageKey();
await this._storage?.set(key, this.playlists());
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async updatePlaylistSongSettings(playlistId: string, songId: string, tonalita: number, speed: number, zoom?: number) {
await this.initPromise; await this.initPromise;
this.playlists.update(p => p.map(pl => { this.playlists.update(p => p.map(pl => {
if (pl.id === playlistId) { if (pl.id === playlistId) {
const songSettings = { ...(pl.songSettings || {}) }; const songSettings = { ...(pl.songSettings || {}) };
songSettings[songId] = { tonalita, speed }; songSettings[songId] = {
tonalita,
speed,
zoom: zoom !== undefined ? zoom : songSettings[songId]?.zoom
};
return { ...pl, songSettings }; return { ...pl, songSettings };
} }
return pl; return pl;
@@ -170,6 +289,9 @@ export class PlaylistService {
const key = this.getPlaylistsStorageKey(); const key = this.getPlaylistsStorageKey();
await this._storage?.set(key, this.playlists()); await this._storage?.set(key, this.playlists());
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
// Also update lastPlaylist if it is the current one // Also update lastPlaylist if it is the current one
const lastKey = `lastPlaylist_${key}`; const lastKey = `lastPlaylist_${key}`;
const last = this.lastPlaylist(); const last = this.lastPlaylist();
@@ -182,9 +304,82 @@ export class PlaylistService {
async deletePlaylist(id: string) { async deletePlaylist(id: string) {
await this.initPromise; await this.initPromise;
if (id.startsWith('remote_')) {
await this.clearRemotePlaylist();
return;
}
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());
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async saveRemotePlaylist(pl: any, customSongs: any[]) {
await this.initPromise;
this.remotePlaylist.set(pl);
this.remoteCustomSongs.set(customSongs);
await this._storage?.set('remote_playlist', pl);
await this._storage?.set('remote_custom_songs', customSongs);
}
async clearRemotePlaylist() {
await this.initPromise;
this.remotePlaylist.set(null);
this.remoteCustomSongs.set([]);
await this._storage?.remove('remote_playlist');
await this._storage?.remove('remote_custom_songs');
// Sincronizza automaticamente in background
this.syncLocalDataToServer();
}
async syncLocalDataToServer() {
await this.initPromise;
const uid = this.settingsService.userUuid();
if (!uid) return;
if (!this.myCantiService) {
this.myCantiService = this.injector.get(MyCantiService);
}
try {
const customSongs = this.myCantiService.myCanti().map(c => ({
id_canti: c.id_canti || Date.now(),
titolo: c.titolo || 'Senza Titolo',
momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[],
testo: c.testo || ''
}));
const playlistSongs = this.playlists().map(pl => ({
id_canti: Number(pl.id) || Date.now(),
titolo: pl.name,
momenti: ['Playlist'],
periodi: pl.songSettings ? [JSON.stringify(pl.songSettings)] : [],
testo: pl.ids.join(',')
}));
const payload = [...customSongs, ...playlistSongs];
const response = await fetch('https://api.canticristiani.it/miei', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-uid': uid
},
body: JSON.stringify(payload)
});
if (!response.ok) {
console.warn(`[Auto-Sync] Server returned code: ${response.status}`);
} else {
console.log('[Auto-Sync] Local playlists and canti backed up successfully.');
}
} catch (err) {
console.error('[Auto-Sync] Auto-synchronization failed:', err);
}
} }
async generateQR(ids: string[], name: string, songSettings?: any): Promise<string> { async generateQR(ids: string[], name: string, songSettings?: any): Promise<string> {
@@ -262,8 +457,67 @@ export class PlaylistService {
} }
async sharePlaylistQR(ids: string[], name: string, songSettings?: any) { async sharePlaylistQR(ids: string[], name: string, songSettings?: any) {
const qrImage = await this.generateQR(ids, name, songSettings); // Sincronizza istantaneamente sul server prima di generare/condividere la playlist
const shareLink = this.getShareLink(ids, name, songSettings); await this.syncLocalDataToServer();
const uid = this.settingsService.userUuid();
const activeId = this.activePlaylistId() || Date.now().toString();
const isRemote = activeId.startsWith('remote_');
const buttons: any[] = [
{
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
handler: () => {
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}`;
if (isRemote) {
let remoteUid = uid;
let remotePid = activeId;
const parts = activeId.split('_');
if (parts.length >= 3) {
remoteUid = parts[1];
remotePid = parts[2];
}
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}`;
}
this.executeShare(shareLink, name);
}
}
];
if (!isRemote) {
buttons.push({
text: 'Modifica (Collaborazione / Backup)',
handler: () => {
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
this.executeShare(shareLink, name + ' (Editor)');
}
});
}
buttons.push({
text: 'Annulla',
role: 'cancel'
});
const alert = await this.alertCtrl.create({
header: 'Condividi Playlist',
message: isRemote ? 'Condividi questa playlist in sola lettura:' : 'Scegli la modalità di condivisione della playlist:',
buttons: buttons
});
await alert.present();
}
private async executeShare(shareLink: string, name: string) {
// Generate QR using the link
const qrImage = await QRCode.toDataURL(shareLink, {
width: 400,
margin: 2,
color: {
dark: '#2d3436',
light: '#ffffff'
}
});
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`; const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
try { try {
+28
View File
@@ -53,6 +53,9 @@ export class SettingsService {
/** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */ /** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */
public karaokePageScrollMode = signal<boolean>(false); public karaokePageScrollMode = signal<boolean>(false);
/** Identificativo utente univoco per la gestione delle comunità */
public userUuid = signal<string>('');
private wakeLock: any = null; private wakeLock: any = null;
// PWA installation signals // PWA installation signals
@@ -63,6 +66,22 @@ export class SettingsService {
public isAndroid = signal<boolean>(false); public isAndroid = signal<boolean>(false);
constructor() { constructor() {
// Gestione/Generazione ID utente univoco
let savedUuid = localStorage.getItem('user-uuid');
if (!savedUuid) {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
savedUuid = crypto.randomUUID();
} else {
savedUuid = '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('user-uuid', savedUuid);
}
this.userUuid.set(savedUuid);
// Detect PWA status // Detect PWA status
try { try {
this.isStandalone.set( this.isStandalone.set(
@@ -117,6 +136,7 @@ export class SettingsService {
localStorage.setItem(migrationKey, 'true'); localStorage.setItem(migrationKey, 'true');
} }
const savedChords = localStorage.getItem('show-chords-default'); const savedChords = localStorage.getItem('show-chords-default');
if (savedChords !== null) { if (savedChords !== null) {
this.showChordsDefault.set(savedChords === 'true'); this.showChordsDefault.set(savedChords === 'true');
@@ -397,6 +417,14 @@ export class SettingsService {
this.chordNotationPreference.set(val); this.chordNotationPreference.set(val);
} }
setUserUuid(uuid: string) {
const trimmed = uuid.trim();
if (trimmed) {
this.userUuid.set(trimmed);
localStorage.setItem('user-uuid', trimmed);
}
}
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.06.06.0038'; export const VERSION = '2026.06.12.1712';
+3 -1
View File
@@ -1,5 +1,7 @@
export const environment = { export const environment = {
production: true, production: true,
contactEmail: 'info@canticristiani.it', contactEmail: 'info@canticristiani.it',
appName: 'CantiCristiani' appName: 'CantiCristiani',
apiAuthUser: 'canti',
apiAuthPass: 'antani2026'
}; };
+3 -1
View File
@@ -5,7 +5,9 @@
export const environment = { export const environment = {
production: false, production: false,
contactEmail: 'info@canticristiani.it', contactEmail: 'info@canticristiani.it',
appName: 'CantiCristiani' appName: 'CantiCristiani',
apiAuthUser: 'canti',
apiAuthPass: 'antani2026'
}; };
/* /*
+37 -5
View File
@@ -37,8 +37,13 @@
<app-root> <app-root>
<div id="pwa-boot-loader" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: #121212; color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif;"> <div id="pwa-boot-loader" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: #121212; color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 99999; font-family: 'Outfit', sans-serif;">
<div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;"> <div style="text-align: center; padding: 20px; max-width: 400px; width: 100%;">
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 10px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2> <div style="margin-bottom: 25px; display: inline-block;">
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 30px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dell'applicazione...</p> <img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.3); border: 2px solid rgba(230, 126, 34, 0.2);">
</div>
<h2 id="pwa-boot-title" style="font-size: 1.8rem; font-weight: 600; margin-bottom: 5px; color: #ffffff; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Avvio in corso</h2>
<p id="pwa-boot-desc" style="font-size: 1rem; color: rgba(255, 255, 255, 0.6); margin-bottom: 15px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased;">Caricamento dei componenti dell'applicazione...</p>
<div id="pwa-boot-version" style="font-size: 0.85rem; color: rgba(255, 255, 255, 0.45); margin-bottom: 20px; font-family: 'Outfit', sans-serif; -webkit-font-smoothing: antialiased; font-weight: 500;">Ricerca versione...</div>
<div id="pwa-boot-phase" style="font-size: 0.95rem; font-weight: 600; color: #fdcb6e; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1.5px; font-family: 'Outfit', sans-serif;">Fase: Avvio</div>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;"> <div style="background: rgba(255, 255, 255, 0.1); border-radius: 10px; height: 8px; width: 100%; overflow: hidden; margin-bottom: 15px;">
<div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div> <div id="pwa-boot-bar" style="background: #e67e22; height: 100%; width: 0%; transition: width 0.1s ease; border-radius: 10px;"></div>
</div> </div>
@@ -50,11 +55,38 @@
const isUpdate = window.location.search.includes('update'); const isUpdate = window.location.search.includes('update');
const titleEl = document.getElementById('pwa-boot-title'); const titleEl = document.getElementById('pwa-boot-title');
const descEl = document.getElementById('pwa-boot-desc'); const descEl = document.getElementById('pwa-boot-desc');
if (isUpdate && titleEl && descEl) { const phaseEl = document.getElementById('pwa-boot-phase');
titleEl.textContent = 'Aggiornamento completato';
descEl.textContent = 'Ottimizzazione e avvio della nuova versione...'; // Detect if it is the first installation (no active service worker controlling the page yet)
const isFirstInstall = 'serviceWorker' in navigator && !navigator.serviceWorker.controller;
if (isUpdate) {
if (titleEl) titleEl.textContent = 'Aggiornamento completato';
if (descEl) descEl.textContent = 'Ottimizzazione e avvio della nuova versione...';
if (phaseEl) phaseEl.textContent = 'Fase: Avvio';
} else if (isFirstInstall) {
if (titleEl) titleEl.textContent = 'Download in corso';
if (descEl) descEl.textContent = 'Scaricamento dei componenti dell\'applicazione...';
if (phaseEl) phaseEl.textContent = 'Fase: Download';
} }
const versionEl = document.getElementById('pwa-boot-version');
fetch('/version.json?cb=' + Date.now())
.then(res => {
if (res.ok) return res.json();
throw new Error('Fallback');
})
.then(data => {
if (data && data.version && versionEl) {
versionEl.textContent = 'Versione ' + data.version;
}
})
.catch(e => {
if (versionEl) {
versionEl.textContent = '';
}
});
let percent = 0; let percent = 0;
const bar = document.getElementById('pwa-boot-bar'); const bar = document.getElementById('pwa-boot-bar');
const pctText = document.getElementById('pwa-boot-percent'); const pctText = document.getElementById('pwa-boot-percent');
+22
View File
@@ -8,5 +8,27 @@ if (environment.production) {
enableProdMode(); enableProdMode();
} }
// Patch window.fetch to automatically inject basic auth header for api.canticristiani.it
const originalFetch = window.fetch;
window.fetch = function(input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url);
if (url.includes('api.canticristiani.it')) {
init = init || {};
init.headers = init.headers || {};
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
if (init.headers instanceof Headers) {
init.headers.set('Authorization', authHeader);
} else if (Array.isArray(init.headers)) {
const hasAuth = init.headers.some(([key]) => key.toLowerCase() === 'authorization');
if (!hasAuth) {
init.headers.push(['Authorization', authHeader]);
}
} else {
(init.headers as Record<string, string>)['Authorization'] = authHeader;
}
}
return originalFetch(input, init);
};
platformBrowserDynamic().bootstrapModule(AppModule) platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err)); .catch(err => console.log(err));