diff --git a/.env b/.env index c219181..1cfc03b 100644 --- a/.env +++ b/.env @@ -2,3 +2,5 @@ FTP_PASSWORD=cantiDavid@72 FTP_THREADS=30 BASE_HREF=/ionic/ CONTACT_EMAIL=info@canticristiani.it +API_AUTH_USER=canti +API_AUTH_PASS=antani2026 diff --git a/allinealetture.sh b/allinealetture.sh index 941d757..607b2c1 100755 --- a/allinealetture.sh +++ b/allinealetture.sh @@ -12,7 +12,7 @@ fi FTP_HOST="ftp.canticristiani.it" FTP_USER="canticristiani.it" 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" if [ -z "$FTP_PASS" ]; then @@ -23,7 +23,7 @@ fi # --- Download del file --- echo "⬇️ Scaricamento dati da $SOURCE_URL..." 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." rm "$TEMP_FILE" exit 1 diff --git a/deploy_localpwa.sh b/deploy_localpwa.sh index f5f5326..2949220 100755 --- a/deploy_localpwa.sh +++ b/deploy_localpwa.sh @@ -17,6 +17,28 @@ VERSION=$(date +'%Y.%m.%d.%H%M') echo "export const VERSION = '$VERSION';" > src/app/version.ts 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 echo "📦 Compilazione in corso (Production Build)..." diff --git a/deploy_www.sh b/deploy_www.sh index 0b5ee90..5d27508 100755 --- a/deploy_www.sh +++ b/deploy_www.sh @@ -9,7 +9,8 @@ else fi # --- Configurazione Parallelismo --- -THREADS=${1:-${FTP_THREADS:-30}} +THREADS=${1:-${FTP_THREADS:-100}} +export FTP_THREADS=$THREADS # --- Configurazione FTP per ROOT www.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 "🏷️ Versione aggiornata a: $VERSION" -# --- Configurazione Email Parametrica --- +# --- 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(\`📧 Aggiornata email di contatto in \${file} a: \${envEmail}\`); + console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`); } }); " diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 45b3d6b..0117f84 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -6,6 +6,12 @@ "start_url": "/", "theme_color": "#3880ff", "background_color": "#ffffff", + "protocol_handlers": [ + { + "protocol": "web+canti", + "url": "/?url=%s" + } + ], "icons": [ { "src": "assets/icons/icon-72x72.png", diff --git a/src/app/app.component.html b/src/app/app.component.html index 7e0399c..757af20 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,22 @@
+ + +
+
+
+ CantiCristiani +
+

Setup in corso

+

Configurazione iniziale e caricamento canti...

+
Versione {{ version }}
+
Fase: Setup
+
+
+
+
{{ cantiService.progress() }}%
+
+
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 0aeaccc..4c14b04 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,5 +1,9 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; 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({ selector: 'app-root', @@ -7,14 +11,100 @@ import { ThemeService } from './services/theme.service'; styleUrls: ['app.component.scss'], standalone: false, }) -export class AppComponent { +export class AppComponent implements OnInit { 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() { // Gli aggiornamenti automatici e periodici sono stati rimossi. // L'aggiornamento viene gestito esclusivamente in modo manuale // 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() { @@ -37,8 +127,13 @@ export function showFullscreenUpdateOverlay() { overlay.innerHTML = `
-

Aggiornamento in corso

-

Installazione della nuova versione...

+
+ CantiCristiani +
+

Download aggiornamento

+

Scaricamento della nuova versione...

+
Ricerca versione...
+
Fase: Download
@@ -47,6 +142,25 @@ export function showFullscreenUpdateOverlay() { `; 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; const interval = setInterval(() => { if (percent < 95) { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 8c8b9d9..f5ac7ea 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,7 +1,7 @@ import { NgModule, isDevMode } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; 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 { IonicModule, IonicRouteStrategy } from '@ionic/angular'; @@ -9,6 +9,7 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; import { ServiceWorkerModule } from '@angular/service-worker'; +import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor'; @NgModule({ declarations: [AppComponent], @@ -24,7 +25,10 @@ import { ServiceWorkerModule } from '@angular/service-worker'; registrationStrategy: 'registerImmediately' }) ], - providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }], + providers: [ + { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, + { provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true } + ], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/src/app/components/identity-qr-modal/identity-qr-modal.component.html b/src/app/components/identity-qr-modal/identity-qr-modal.component.html new file mode 100644 index 0000000..6067181 --- /dev/null +++ b/src/app/components/identity-qr-modal/identity-qr-modal.component.html @@ -0,0 +1,41 @@ + + + QR Code di Ripristino + + + + + + + + + +
+

+ 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. +

+ +
+
+ QR Code di Ripristino +
+ +
+ Codice Identificativo: + {{ userUuid }} +
+
+ +
+ + + Copia Codice Testuale + + + + + Scarica Immagine QR + +
+
+
diff --git a/src/app/components/identity-qr-modal/identity-qr-modal.component.scss b/src/app/components/identity-qr-modal/identity-qr-modal.component.scss new file mode 100644 index 0000000..25e4fe3 --- /dev/null +++ b/src/app/components/identity-qr-modal/identity-qr-modal.component.scss @@ -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); + } +} diff --git a/src/app/components/identity-qr-modal/identity-qr-modal.component.ts b/src/app/components/identity-qr-modal/identity-qr-modal.component.ts new file mode 100644 index 0000000..adf2df0 --- /dev/null +++ b/src/app/components/identity-qr-modal/identity-qr-modal.component.ts @@ -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(); + } +} diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index aa9f561..45c243b 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -7,7 +7,9 @@ {{ appName }} v{{ version }} - + + +
@@ -68,14 +70,13 @@ - Miei - + {{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }} + + @@ -85,7 +86,7 @@ size="small" (click)="toggleFilterType('liturgico')" class="filter-chip"> - {{ selectedLiturgico() !== null ? getSelectedLiturgicoLabel() : 'Liturgia' }} + {{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }} @@ -97,7 +98,7 @@ size="small" (click)="toggleFilterType('tematico')" class="filter-chip"> - {{ selectedTematico() !== null ? getSelectedTematicoLabel() : 'Periodo' }} + {{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }} @@ -120,7 +121,7 @@ size="small" (click)="playlistService.allPlaylists().length > 0 ? toggleFilterType('playlist') : playlistService.toggleSelectionMode()" class="filter-chip"> - {{ playlistService.activeListName() !== null ? playlistService.activeListName() : 'Playlist' }} + {{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }} @@ -145,7 +146,7 @@ - +
@@ -162,7 +163,7 @@
-
+
@@ -170,20 +171,37 @@ - +
-
- - {{ activeFilterType() === 'playlist' ? item.name : item.tag_name }} -
+ +
+ Validati +
+
+ Non Validati +
+
+ + +
+ + {{ activeFilterType() === 'playlist' ? item.name : item.tag_name }} +
+
@@ -284,7 +302,7 @@
-
+
{{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }} @@ -297,7 +315,8 @@

{{ getCommunitySongNumber(canto) }} {{ canto.titolo }} - Non Validato + Remoto + Mio

{{ canto.autore || 'Autore sconosciuto' }} diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 7ae63f8..07bd10e 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -946,12 +946,24 @@ ion-title { margin-left: 6px; display: inline-block; 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 { background: rgba(231, 76, 60, 0.1) !important; 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; + } } /* ========================================================================== diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 43c21c9..d25bbb7 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -33,8 +33,10 @@ export class HomePage implements OnDestroy { public showOnlyMine = signal(false); public showTopTen = signal(false); public showSuggeriti = signal(false); + public showValidati = signal(false); + public showNonValidati = signal(false); public isMassCardExpanded = signal(false); - public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null); + public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null); public loadedThumbs = new Set(); public version = VERSION; public appName = environment.appName; @@ -44,6 +46,7 @@ export class HomePage implements OnDestroy { public showAndroidBanner = signal(false); public showIosTooltip = signal(false); + public hasUpdateAvailable = signal(false); public fontSize = signal(1.0); public youtubePlayerService = inject(YoutubePlayerService); @@ -73,6 +76,7 @@ export class HomePage implements OnDestroy { private swUpdate = inject(SwUpdate); private firstInteraction = true; + private updatePollInterval: any = null; async checkForAppUpdate(event?: Event) { 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() { if (this.firstInteraction) { this.firstInteraction = false; @@ -241,10 +263,11 @@ export class HomePage implements OnDestroy { const comunitaIds = this.comunitaService.comunitaCantiIds(); 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 = [ ...this.cantiService.canti(), ...this.myCantiService.myCanti(), + ...this.playlistService.remoteCustomSongs(), ...this.comunitaService.comunitaCantiPersonali() ]; @@ -278,8 +301,12 @@ export class HomePage implements OnDestroy { return numA - numB; }); } else { - // General context: only standard canti + my canti - list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()]; + // General context: only standard canti + my canti + remote custom canti + list = [ + ...this.cantiService.canti(), + ...this.myCantiService.myCanti(), + ...this.playlistService.remoteCustomSongs() + ]; } // 2. Cumulative filtering @@ -290,7 +317,7 @@ export class HomePage implements OnDestroy { } // A. Filter by Playlist (if activeIds is present) - if (activeIds.length > 0 && !selectionMode) { + if (activeIds.length > 0 && (!selectionMode || this.playlistService.activePlaylistId() === null)) { list = activeIds .map(id => list.find(c => c.id === id)) .filter((c): c is any => !!c); @@ -302,6 +329,16 @@ export class HomePage implements OnDestroy { 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 if (litId !== null) { list = list.filter(c => c.id_momenti?.includes(litId)); @@ -377,6 +414,21 @@ export class HomePage implements OnDestroy { }); 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 const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true'; const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true'; @@ -411,6 +463,8 @@ export class HomePage implements OnDestroy { this.showOnlyMine.set(false); this.showTopTen.set(false); this.showSuggeriti.set(false); + this.showValidati.set(false); + this.showNonValidati.set(false); this.playlistService.activeListIds.set([]); this.playlistService.activeListName.set(null); this.playlistService.activePlaylistId.set(null); @@ -432,6 +486,12 @@ export class HomePage implements OnDestroy { if (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; @@ -443,7 +503,7 @@ export class HomePage implements OnDestroy { if (selectionMode) { if (!prevSelectionMode) { - if (activeIds.length === 0) { + if (activeIds.length === 0 || this.playlistService.activePlaylistId() === null) { this.isAddingSongs.set(true); } else { this.isAddingSongs.set(false); @@ -476,7 +536,12 @@ export class HomePage implements OnDestroy { updatedList = [...updatedList, ...newSongs]; } - this.reorderList.set(updatedList); + // 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); + } } } else { prevSelectionMode = 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() { } ngOnDestroy() { this.audioEngine.stopSearchRecognition(); + if (this.updatePollInterval) { + clearInterval(this.updatePollInterval); + } } onSearch(event: any) { @@ -619,8 +873,9 @@ export class HomePage implements OnDestroy { return [ ...this.cantiService.canti(), ...this.myCantiService.myCanti(), + ...this.playlistService.remoteCustomSongs(), ...this.comunitaService.comunitaCantiPersonali() - ].find(c => c.id === id); + ].find(c => c.id === id || String(c.id_canti) === id); } getPlayingCanto() { @@ -643,6 +898,7 @@ export class HomePage implements OnDestroy { toggleOnlyMine() { this.showOnlyMine.update(v => !v); + this.activeFilterType.set(null); this.limit.set(10); } @@ -652,13 +908,24 @@ export class HomePage implements OnDestroy { this.limit.set(10); } - toggleIndex(id: number, type: 'liturgico' | 'tematico' | 'playlist') { + toggleIndex(id: number, type: 'liturgico' | 'tematico' | 'playlist' | 'lista_completa') { if (type === 'liturgico') { this.selectedLiturgico.update(cur => cur === id ? null : id); } else if (type === 'tematico') { this.selectedTematico.update(cur => cur === id ? null : id); } - this.activeFilterType.set(null); + + // 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.limit.set(10); } @@ -734,6 +1001,8 @@ export class HomePage implements OnDestroy { this.showOnlyMine() || this.showTopTen() || this.showSuggeriti() || + this.showValidati() || + this.showNonValidati() || this.playlistService.activeListName() !== null || this.searchQuery() !== ''; } @@ -744,6 +1013,8 @@ export class HomePage implements OnDestroy { this.showOnlyMine.set(false); this.showTopTen.set(false); this.showSuggeriti.set(false); + this.showValidati.set(false); + this.showNonValidati.set(false); this.playlistService.activeListIds.set([]); this.playlistService.activeListName.set(null); this.playlistService.activePlaylistId.set(null); @@ -752,9 +1023,23 @@ export class HomePage implements OnDestroy { this.limit.set(10); } - toggleFilterType(type: 'liturgico' | 'tematico' | 'playlist') { + toggleFilterType(type: 'liturgico' | 'tematico' | 'playlist' | 'lista_completa') { if (this.activeFilterType() === type) { - this.activeFilterType.set(null); + // 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); + } } else { this.activeFilterType.set(type); } @@ -764,12 +1049,13 @@ export class HomePage implements OnDestroy { this.playlistService.activeListIds.set(pl.ids); this.playlistService.activeListName.set(pl.name); 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); } toggleTopTen() { this.showTopTen.update(v => !v); + this.activeFilterType.set(null); this.limit.set(30); } @@ -779,8 +1065,53 @@ export class HomePage implements OnDestroy { 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() { this.showSuggeriti.update(v => !v); + this.activeFilterType.set(null); this.limit.set(30); } @@ -996,6 +1327,42 @@ export class HomePage implements OnDestroy { handleScannedData(data: string) { 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=')) { try { let base64 = ''; @@ -1115,6 +1482,11 @@ export class HomePage implements OnDestroy { return !!id && id.startsWith('comunita_'); } + isRemotePlaylist(): boolean { + const id = this.playlistService.activePlaylistId(); + return !!id && id.startsWith('remote_'); + } + isActivePlaylistSaved(): boolean { const id = this.playlistService.activePlaylistId(); if (!id) return false; @@ -1164,16 +1536,22 @@ export class HomePage implements OnDestroy { const name = this.playlistService.activeListName(); if (!id || !name) return; + const isRemote = this.isRemotePlaylist(); + const alert = await this.alertCtrl.create({ - header: 'Elimina Playlist', - message: `Vuoi davvero eliminare "${name}"?`, + header: isRemote ? 'Rimuovi Playlist' : 'Elimina Playlist', + message: isRemote ? `Vuoi davvero rimuovere la playlist "${name}"?` : `Vuoi davvero eliminare "${name}"?`, buttons: [ { text: 'Annulla', role: 'cancel' }, { - text: 'Elimina', + text: isRemote ? 'Rimuovi' : 'Elimina', role: 'destructive', handler: () => { - this.playlistService.deletePlaylist(id); + if (isRemote) { + this.playlistService.clearRemotePlaylist(); + } else { + this.playlistService.deletePlaylist(id); + } this.clearSpecialList(); } } @@ -1203,6 +1581,31 @@ export class HomePage implements OnDestroy { 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) { const arr = [...this.reorderList()]; moveItemInArray(arr, event.previousIndex, event.currentIndex); @@ -1233,6 +1636,7 @@ export class HomePage implements OnDestroy { } else { this.comunitaService.isFilterActive.update(v => !v); } + this.activeFilterType.set(null); } editComunitaCode(event: Event) { diff --git a/src/app/interceptors/api-auth.interceptor.ts b/src/app/interceptors/api-auth.interceptor.ts new file mode 100644 index 0000000..a6b298e --- /dev/null +++ b/src/app/interceptors/api-auth.interceptor.ts @@ -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, next: HttpHandler): Observable> { + 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); + } +} diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index 0bfd35b..04a67b3 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -156,9 +156,16 @@

- - - +
+ + + +
+ + {{ faceDetector.currentTiltAngle() }}° +
+
@@ -208,12 +215,5 @@ - -
- -
-
- {{ faceDetector.currentTiltAngle() }}° -
-
-
+ + diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index f137f1c..02e40c8 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -257,6 +257,28 @@ 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 @@ -741,16 +763,17 @@ ion-content.full-screen-content { position: fixed; bottom: 60px; right: 15px; - width: 90px; - height: 120px; - border-radius: 12px; - overflow: hidden; + padding: 8px 14px; + border-radius: 20px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.4); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); z-index: 1000; display: flex; - flex-direction: column; - background: #000; + align-items: center; + justify-content: center; + background: rgba(18, 18, 18, 0.85); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); @media (orientation: landscape) { bottom: 15px; @@ -758,27 +781,24 @@ ion-content.full-screen-content { } video { - width: 100%; - height: 100%; - object-fit: cover; - transform: scaleX(-1); // Mirror camera preview + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; } .camera-preview-overlay { - position: absolute; - bottom: 0; - left: 0; - right: 0; - background: rgba(0, 0, 0, 0.6); - padding: 2px 0; display: flex; justify-content: center; align-items: center; .angle-indicator { - font-size: 0.75rem; + font-size: 0.85rem; font-weight: 700; color: #fff; + display: flex; + align-items: center; &.tilted { color: #2ed573; diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index a7f9f51..634045b 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -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 { ActivatedRoute, Router } from '@angular/router'; import { AlertController, ToastController, GestureController } from '@ionic/angular'; @@ -177,19 +177,40 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (!found) { 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) { found = comunitaCantiPers.find(c => c.id === id); } if (found) { - const current = this.canto(); - // Avoid duplicate triggers for the same song - if (!current || current.id !== found.id) { + const current = untracked(() => this.canto()); + // Avoid duplicate triggers for the same song, but update if song content changed + 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.canto.set(found); + this.currentLineIndex.set(0); + this.lastScrollBlock.set('start'); this.songStartTime = Date.now(); this.cantiService.getStorage()?.set('last_song_id', 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(); let playlistSongSetting: any = null; if (activePlaylistId) { - const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId); - if (pl && pl.songSettings && pl.songSettings[c.id]) { - playlistSongSetting = pl.songSettings[c.id]; + if (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); + if (pl && pl.songSettings && pl.songSettings[c.id]) { + playlistSongSetting = pl.songSettings[c.id]; + } } } if (playlistSongSetting) { this.transposeAmount.set(playlistSongSetting.tonalita !== undefined ? playlistSongSetting.tonalita : 0); this.autoscrollSpeed.set(playlistSongSetting.speed !== undefined ? playlistSongSetting.speed : 2); + if (playlistSongSetting.zoom !== undefined) { + this.fontSize.set(playlistSongSetting.zoom); + } else { + this.fontSize.set(1.0); + } } else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) { const settings = this.comunitaService.comunitaCantiSettings(); const songSetting = settings.find(s => s.id_canti === c.id_canti || s.id_canti === Number(c.id)); @@ -322,6 +355,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { onTouchEnd() { this.initialPinchDistance = null; + this.updatePlaylistSettings(); } private getDistance(t1: Touch, t2: Touch): number { @@ -334,9 +368,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.transposeAmount.set(0); } - transposeUp() { - this.transposeAmount.update(v => v + 1); - + private updatePlaylistSettings() { const c = this.canto(); if (c) { const dbSpeed = this.autoscrollSpeed() * 100; @@ -344,30 +376,35 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { const activePlaylistId = this.playlistService.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() { this.transposeAmount.update(v => v - 1); - - const c = this.canto(); - if (c) { - const dbSpeed = this.autoscrollSpeed() * 100; - this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed); - - const activePlaylistId = this.playlistService.activePlaylistId(); - if (activePlaylistId) { - this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed()); - } - } + this.updatePlaylistSettings(); } zoomIn() { if (this.fontSize() < 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.updatePlaylistSettings(); } } @@ -375,6 +412,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (this.fontSize() > 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.updatePlaylistSettings(); } } @@ -857,30 +895,12 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { increaseAutoscrollSpeed() { this.autoscrollSpeed.update(s => Math.min(10, s + 1)); - const c = this.canto(); - if (c) { - const dbSpeed = this.autoscrollSpeed() * 100; - this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed); - - const activePlaylistId = this.playlistService.activePlaylistId(); - if (activePlaylistId) { - this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed()); - } - } + this.updatePlaylistSettings(); } decreaseAutoscrollSpeed() { this.autoscrollSpeed.update(s => Math.max(1, s - 1)); - const c = this.canto(); - if (c) { - const dbSpeed = this.autoscrollSpeed() * 100; - this.statsService.updateSongSettings(c.id_canti, this.transposeAmount(), dbSpeed); - - const activePlaylistId = this.playlistService.activePlaylistId(); - if (activePlaylistId) { - this.playlistService.updatePlaylistSongSettings(activePlaylistId, c.id, this.transposeAmount(), this.autoscrollSpeed()); - } - } + this.updatePlaylistSettings(); } private logPreviousSongTime() { diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index e81dfc2..3ca3536 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -5,8 +5,9 @@ import { IonicModule, ToastController, IonTextarea, PopoverController, NavContro import { createWorker } from 'tesseract.js'; import { CantiService } from '../../services/canti.service'; import { MyCantiService } from '../../services/my-canti.service'; +import { PlaylistService } from '../../services/playlist.service'; import { ThemeService } from '../../services/theme.service'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { ActivatedRoute, RouterModule, Router } from '@angular/router'; @Component({ selector: 'app-propose-canto', @@ -21,9 +22,11 @@ export class ProposeCantoPage implements OnInit { public cantiService = inject(CantiService); private myCantiService = inject(MyCantiService); + private playlistService = inject(PlaylistService); private navCtrl = inject(NavController); public themeService = inject(ThemeService); private route = inject(ActivatedRoute); + private router = inject(Router); title: string = ''; author: string = ''; @@ -112,10 +115,11 @@ export class ProposeCantoPage implements OnInit { const editId = params['editId']; if (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 = [ ...this.cantiService.canti(), - ...this.myCantiService.myCanti() + ...this.myCantiService.myCanti(), + ...this.playlistService.remoteCustomSongs() ].find(c => c.id === editId); if (song) { @@ -128,8 +132,8 @@ export class ProposeCantoPage implements OnInit { const litIds = this.cantiService.indiceLiturgico().map(m => m.id); const temIds = this.cantiService.indiceTematico().map(m => m.id); - this.selectedLiturgico = song.id_momenti?.filter(id => litIds.includes(id)) || []; - this.selectedTematico = song.id_momenti?.filter(id => temIds.includes(id)) || []; + this.selectedLiturgico = song.id_momenti?.filter((id: number) => litIds.includes(id)) || []; + this.selectedTematico = song.id_momenti?.filter((id: number) => temIds.includes(id)) || []; } } }); @@ -749,16 +753,62 @@ export class ProposeCantoPage implements OnInit { // Combine lit and tematico for id_momenti const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico]; - await this.myCantiService.saveCanto({ - id: this.editId || undefined, - titolo: this.title, - autore: this.author, - link_youtube: this.youtubeLink, - testo: this.content, - accordi: this.content, // Save to both fields for compatibility - id_momenti: id_momenti - }); + const activePlaylistId = this.playlistService.activePlaylistId(); + const isRemotePlaylist = activePlaylistId && activePlaylistId.startsWith('remote_'); - this.navCtrl.back(); + 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, + titolo: this.title, + autore: this.author, + link_youtube: this.youtubeLink, + testo: this.content, + accordi: this.content, // Save to both fields for compatibility + 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(); + } + } } } diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index c95afc5..42a2be6 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -156,6 +156,47 @@
+ +
+
+

+ Identità Utente e Ripristino +

+
+ +
+

+ 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. +

+ +
+
+
Codice ID
+
{{ settingsService.userUuid() }}
+
+ + + +
+ +
+ + + Scansiona QR + + + + Inserisci Codice + +
+ + + + Salva Backup sul Server + +
+
+
diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index 08e0ff2..3c633f0 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -4,7 +4,7 @@ import { SettingsService } from '../../services/settings.service'; import { CantiService } from '../../services/canti.service'; import { ConnectivityService } from '../../services/connectivity.service'; 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 { PlaylistService } from '../../services/playlist.service'; import { Router } from '@angular/router'; @@ -15,6 +15,8 @@ import { CantiLettureService } from '../../services/canti-letture.service'; import { ComunitaService } from '../../services/comunita.service'; import { environment } from '../../../environments/environment'; 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({ selector: 'app-settings', @@ -35,6 +37,7 @@ export class SettingsPage { private toastCtrl = inject(ToastController); private modalCtrl = inject(ModalController); private alertCtrl = inject(AlertController); + private loadingCtrl = inject(LoadingController); private router = inject(Router); public version = VERSION; @@ -52,6 +55,209 @@ export class SettingsPage { 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) { this.settingsService.setShowChordsDefault(event.detail.value === 'chords'); } diff --git a/src/app/services/canti-letture.service.ts b/src/app/services/canti-letture.service.ts index b65cd00..e039439 100644 --- a/src/app/services/canti-letture.service.ts +++ b/src/app/services/canti-letture.service.ts @@ -45,7 +45,7 @@ export class CantiLettureService { public suggestionsMap = signal>(new Map()); // id_canto -> peso public suggestionsMomentsMap = signal>(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'; constructor() { diff --git a/src/app/services/canti.service.ts b/src/app/services/canti.service.ts index 5663d83..6c93230 100644 --- a/src/app/services/canti.service.ts +++ b/src/app/services/canti.service.ts @@ -44,6 +44,7 @@ export class CantiService { public momenti = signal([]); public loading = signal(false); public progress = signal(0); + public firstLoadCompleted = signal(false); private API_URL = 'https://www.canticristiani.it/api/canti.json'; @@ -55,6 +56,9 @@ export class CantiService { const storage = await this.storage.create(); this._storage = storage; await this.loadFromStorage(); + if (this.canti() && this.canti().length > 0) { + this.firstLoadCompleted.set(true); + } this.refresh(); } @@ -132,11 +136,13 @@ export class CantiService { } this.progress.set(100); this.loading.set(false); + this.firstLoadCompleted.set(true); } }, error: (error) => { console.error('Failed to fetch canti', error); this.loading.set(false); + this.firstLoadCompleted.set(true); } }); } diff --git a/src/app/services/my-canti.service.ts b/src/app/services/my-canti.service.ts index af290aa..008b26f 100644 --- a/src/app/services/my-canti.service.ts +++ b/src/app/services/my-canti.service.ts @@ -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 { Canto, CantiService } from './canti.service'; import { ToastController } from '@ionic/angular'; import { environment } from '../../environments/environment'; +import { PlaylistService } from './playlist.service'; @Injectable({ providedIn: 'root' @@ -11,6 +12,8 @@ export class MyCantiService { private storage = inject(Storage); private cantiService = inject(CantiService); private toastController = inject(ToastController); + private injector = inject(Injector); + private playlistService!: PlaylistService; private _storage: Storage | null = null; public myCanti = signal([]); @@ -56,9 +59,11 @@ export class MyCantiService { }); // Fallback if not found in list (should not happen normally) 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 = { id: canto.id, - id_canti: canto.id_canti || Date.now(), + id_canti: canto.id_canti || idCanti, titolo: canto.titolo || 'Senza Titolo', testo: canto.testo || '', accordi: canto.accordi, @@ -86,6 +91,12 @@ export class MyCantiService { this.myCanti.set(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({ message: 'Canto salvato nei "Miei Canti"!', duration: 2000, @@ -100,6 +111,12 @@ export class MyCantiService { const updated = this.myCanti().filter(c => c.id !== id); this.myCanti.set(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() { diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts index bf93d69..92f0eac 100644 --- a/src/app/services/playlist.service.ts +++ b/src/app/services/playlist.service.ts @@ -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 { Canto, CantiService } from './canti.service'; import { ComunitaService } from './comunita.service'; 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({ providedIn: 'root' @@ -13,6 +15,10 @@ export class PlaylistService { private cantiService = inject(CantiService); private comunitaService = inject(ComunitaService); private toastCtrl = inject(ToastController); + private alertCtrl = inject(AlertController); + private settingsService = inject(SettingsService); + private injector = inject(Injector); + private myCantiService!: MyCantiService; public selectionMode = signal(false); public selectedIds = signal>(new Set()); @@ -24,6 +30,9 @@ export class PlaylistService { public activeListName = signal(null); public activePlaylistId = signal(null); + public remotePlaylist = signal(null); + public remoteCustomSongs = signal([]); + private _storage: Storage | null = null; private initPromise!: Promise; @@ -41,14 +50,20 @@ export class PlaylistService { })); }); - // Merged list: personal playlists + community scalette + // Merged list: personal playlists + community scalette + remote playlist public allPlaylists = computed(() => { const community = this.comunitaPlaylists(); const personal = this.playlists(); + const remote = this.remotePlaylist(); + + let list = [...personal.map(p => ({ ...p, isComunita: false, isRemote: false }))]; 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() { @@ -65,7 +80,7 @@ export class PlaylistService { }, { allowSignalWrites: true }); } - private getPlaylistsStorageKey(): string { + getPlaylistsStorageKey(): string { const code = this.comunitaService.comunitaCode(); const isCommunityActive = this.comunitaService.isFilterActive(); if (code && isCommunityActive) { @@ -89,6 +104,78 @@ export class PlaylistService { const lastKey = `lastPlaylist_${key}`; const last = await this._storage.get(lastKey); 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() { @@ -154,14 +241,46 @@ export class PlaylistService { this.selectedIds.set(new Set()); this.selectionMode.set(false); 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; this.playlists.update(p => p.map(pl => { if (pl.id === playlistId) { const songSettings = { ...(pl.songSettings || {}) }; - songSettings[songId] = { tonalita, speed }; + songSettings[songId] = { + tonalita, + speed, + zoom: zoom !== undefined ? zoom : songSettings[songId]?.zoom + }; return { ...pl, songSettings }; } return pl; @@ -169,6 +288,9 @@ export class PlaylistService { const key = this.getPlaylistsStorageKey(); await this._storage?.set(key, this.playlists()); + + // Sincronizza automaticamente in background + this.syncLocalDataToServer(); // Also update lastPlaylist if it is the current one const lastKey = `lastPlaylist_${key}`; @@ -182,9 +304,82 @@ export class PlaylistService { async deletePlaylist(id: string) { await this.initPromise; + if (id.startsWith('remote_')) { + await this.clearRemotePlaylist(); + return; + } const key = this.getPlaylistsStorageKey(); this.playlists.update(p => p.filter(pl => pl.id !== id)); 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 { @@ -262,8 +457,67 @@ export class PlaylistService { } async sharePlaylistQR(ids: string[], name: string, songSettings?: any) { - const qrImage = await this.generateQR(ids, name, songSettings); - const shareLink = this.getShareLink(ids, name, songSettings); + // Sincronizza istantaneamente sul server prima di generare/condividere la playlist + 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`; try { diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 6c5ab97..c991d88 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -53,6 +53,9 @@ export class SettingsService { /** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */ public karaokePageScrollMode = signal(false); + /** Identificativo utente univoco per la gestione delle comunità */ + public userUuid = signal(''); + private wakeLock: any = null; // PWA installation signals @@ -63,6 +66,22 @@ export class SettingsService { public isAndroid = signal(false); 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 try { this.isStandalone.set( @@ -117,6 +136,7 @@ export class SettingsService { localStorage.setItem(migrationKey, 'true'); } + const savedChords = localStorage.getItem('show-chords-default'); if (savedChords !== null) { this.showChordsDefault.set(savedChords === 'true'); @@ -397,6 +417,14 @@ export class SettingsService { this.chordNotationPreference.set(val); } + setUserUuid(uuid: string) { + const trimmed = uuid.trim(); + if (trimmed) { + this.userUuid.set(trimmed); + localStorage.setItem('user-uuid', trimmed); + } + } + async installPwa() { const promptEvent = this.deferredPrompt(); if (!promptEvent) { diff --git a/src/app/version.ts b/src/app/version.ts index ee0bf97..2a45f40 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.06.06.0038'; +export const VERSION = '2026.06.12.1712'; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index fceaae4..04b84d5 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,5 +1,7 @@ export const environment = { production: true, contactEmail: 'info@canticristiani.it', - appName: 'CantiCristiani' + appName: 'CantiCristiani', + apiAuthUser: 'canti', + apiAuthPass: 'antani2026' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 2321f7c..9c277a7 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -5,7 +5,9 @@ export const environment = { production: false, contactEmail: 'info@canticristiani.it', - appName: 'CantiCristiani' + appName: 'CantiCristiani', + apiAuthUser: 'canti', + apiAuthPass: 'antani2026' }; /* diff --git a/src/index.html b/src/index.html index 670729e..380f9d0 100644 --- a/src/index.html +++ b/src/index.html @@ -37,8 +37,13 @@
-

Avvio in corso

-

Caricamento dell'applicazione...

+
+ CantiCristiani +
+

Avvio in corso

+

Caricamento dei componenti dell'applicazione...

+
Ricerca versione...
+
Fase: Avvio
@@ -50,11 +55,38 @@ const isUpdate = window.location.search.includes('update'); const titleEl = document.getElementById('pwa-boot-title'); const descEl = document.getElementById('pwa-boot-desc'); - if (isUpdate && titleEl && descEl) { - titleEl.textContent = 'Aggiornamento completato'; - descEl.textContent = 'Ottimizzazione e avvio della nuova versione...'; + const phaseEl = document.getElementById('pwa-boot-phase'); + + // 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; const bar = document.getElementById('pwa-boot-bar'); const pctText = document.getElementById('pwa-boot-percent'); diff --git a/src/main.ts b/src/main.ts index 91ec6da..306d1d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,5 +8,27 @@ if (environment.production) { 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)['Authorization'] = authHeader; + } + } + return originalFetch(input, init); +}; + platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.log(err));