diff --git a/src/app/app.component.html b/src/app/app.component.html index 757af20..72b09b9 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -2,21 +2,100 @@
- -
-
+ +
+
- CantiCristiani + CantiCristiani
-

Setup in corso

-

Configurazione iniziale e caricamento canti...

-
Versione {{ version }}
-
Fase: Setup
-
-
+

Apertura App in corso

+

+ Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta. +

+ + +
+ + +
+ + +
+
+
+ + +
+ + +
+
+ CantiCristiani +
+

Installa CantiCristiani

+

+ Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser, aprirla all'istante ed usarla anche offline in chiesa! +

+ +
+ + +
+
+ + +
+
+
+ CantiCristiani +
+

Aggiungi a Home (iOS)

+

+ Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS: +

+
+ +
+
+
1
+
+ Tocca il pulsante Condividi 📤 in Safari. +
+
+
+
2
+
+ Scorri il menu e seleziona Aggiungi alla schermata Home âž•. +
+
+
+ +
+
-
{{ cantiService.progress() }}%
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4c14b04..ee57c63 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,9 +1,12 @@ -import { Component, inject, OnInit } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { ThemeService } from './services/theme.service'; import { CantiService } from './services/canti.service'; +import { SettingsService } from './services/settings.service'; import { VERSION } from './version'; import { Router, ActivatedRoute } from '@angular/router'; import { ToastController } from '@ionic/angular'; +import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; +import { filter, first } from 'rxjs/operators'; @Component({ selector: 'app-root', @@ -14,10 +17,16 @@ import { ToastController } from '@ionic/angular'; export class AppComponent implements OnInit { private themeService = inject(ThemeService); // Ensures theme is initialized at boot public cantiService = inject(CantiService); + public settingsService = inject(SettingsService); public version = VERSION; private router = inject(Router); private route = inject(ActivatedRoute); private toastCtrl = inject(ToastController); + private swUpdate = inject(SwUpdate); + + public showRedirectOverlay = signal(false); + public showInstallOverlay = signal(false); + public protocolLink = ''; constructor() { // Gli aggiornamenti automatici e periodici sono stati rimossi. @@ -25,7 +34,28 @@ export class AppComponent implements OnInit { // tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage. } - ngOnInit() { + async ngOnInit() { + // 1. Allineamento istantaneo alla versione remota + if ((window as any).PwaLoader) { + (window as any).PwaLoader.update({ + phase: 'Fase: Verifica Versione', + desc: 'Verifica della versione più recente in corso...' + }); + } + + const updated = await this.checkVersionSync(); + if (updated) { + return; // Reloading, skip further setup + } + + // Set signal indicating startup version check is complete + this.settingsService.isVersionCheckComplete.set(true); + + // Hide loader if canti are also already loaded + if (this.cantiService.firstLoadCompleted() && (window as any).PwaLoader) { + (window as any).PwaLoader.hide(); + } + this.route.queryParams.subscribe(params => { const protocolUrl = params['url']; if (protocolUrl && protocolUrl.startsWith('web+canti:')) { @@ -55,92 +85,234 @@ export class AppComponent implements OnInit { this.checkAndRedirectToPwa(); } + async checkVersionSync(): Promise { + // Controlla SEMPRE version.json per primo — è il modo più affidabile per + // rilevare un disallineamento di versione, indipendentemente dallo stato del SW. + // Su mobile, checkForUpdate() può essere lento o inaffidabile. + try { + console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`); + const response = await Promise.race([ + fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' }), + new Promise((resolve) => setTimeout(() => resolve(null), 5000)) + ]); + if (response && response.ok) { + const data = await response.json(); + if (data && data.version && data.version !== VERSION) { + console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Forza aggiornamento...`); + const overlay = showFullscreenUpdateOverlay(); + + // Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW) + if (this.swUpdate.isEnabled) { + try { + const hasSwUpdate = await Promise.race([ + this.swUpdate.checkForUpdate(), + new Promise((resolve) => setTimeout(() => resolve(false), 8000)) + ]); + if (hasSwUpdate) { + console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...'); + await Promise.race([ + this.swUpdate.activateUpdate(), + new Promise((resolve) => setTimeout(resolve, 5000)) + ]); + } + } catch (e) { + console.warn('[PWA-Update] SwUpdate durante mismatch fallito:', e); + } + } + + // Aggiorna anche la registrazione SW direttamente (doppia sicurezza) + if ('serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.ready; + await registration.update(); + } catch (e) { + console.warn('[PWA-Update] SW registration.update fallito:', e); + } + } + + // Disattiva service worker attivi per forzare il refresh completo + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.unregister(); + } + } + + // Cancella le cache del browser + if ('caches' in window) { + const keys = await caches.keys(); + for (const key of keys) { + await caches.delete(key); + } + } + + overlay.finish(); + + // Ricarica con parametro cache-busting per forzare l'allineamento remoto + const url = new URL(window.location.href); + url.searchParams.set('update_cb', Date.now().toString()); + window.location.replace(url.toString()); + return true; + } else { + console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.'); + } + } + } catch (e) { + console.warn('[PWA-Update] version.json check fallito:', e); + } + + // 2. Fallback: Prova SwUpdate nel caso in cui il controllo version.json sia fallito o sia stato servito dalla cache + if (this.swUpdate.isEnabled) { + try { + console.log('[PWA-Update] Verifica aggiornamenti via SwUpdate all\'avvio...'); + + let activated = false; + let overlay: any = null; + + const activateAndReload = async () => { + if (activated) return; + activated = true; + try { + await this.swUpdate.activateUpdate(); + } catch (e) { + console.warn('[PWA-Update] activateUpdate fallito all\'avvio:', e); + } + + // Deregistra i vecchi SW e cancella le cache per un ricaricamento pulito + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const reg of registrations) { + await reg.unregister(); + } + } + if ('caches' in window) { + const keys = await caches.keys(); + for (const key of keys) { + await caches.delete(key); + } + } + + if (overlay) overlay.finish(); + setTimeout(() => { + const url = new URL(window.location.href); + url.searchParams.set('update_cb', Date.now().toString()); + window.location.replace(url.toString()); + }, 600); + }; + + // Sottoscrivi PRIMA di verificare l'aggiornamento per evitare race condition + const sub = this.swUpdate.versionUpdates + .pipe( + filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), + first() + ) + .subscribe(() => { + console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio'); + activateAndReload(); + }); + + // Concedi fino a 8 secondi al controllo SW — le connessioni mobili possono essere lente + const hasUpdate = await Promise.race([ + this.swUpdate.checkForUpdate(), + new Promise((resolve) => setTimeout(() => resolve(false), 8000)) + ]); + + if (hasUpdate) { + console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...'); + overlay = showFullscreenUpdateOverlay(); + + // Timeout di sicurezza di 25 secondi: se VERSION_READY non arriva, attiva comunque + setTimeout(() => { + console.log('[PWA-Update] Safety timeout raggiunto all\'avvio, procedo...'); + sub.unsubscribe(); + activateAndReload(); + }, 25000); + + return true; // Attendi il ricaricamento + } else { + sub.unsubscribe(); + } + } catch (err) { + console.warn('[PWA-Update] Controllo SwUpdate fallito all\'avvio:', err); + } + } + + return false; + } + async checkAndRedirectToPwa() { const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone; if (isStandalone) { + localStorage.setItem('pwa-installed', 'true'); 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; + this.protocolLink = `web+canti://open${path}${search}`; + + // Controlliamo se abbiamo già salvato che l'app è installata o se possiamo verificarlo + let isInstalled = localStorage.getItem('pwa-installed') === 'true'; + if (!isInstalled && 'getInstalledRelatedApps' in navigator) { + try { + const relatedApps = await (navigator as any).getInstalledRelatedApps(); + isInstalled = relatedApps.length > 0; + if (isInstalled) { + localStorage.setItem('pwa-installed', 'true'); } - } - - 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(); + } catch (e) { + console.warn('Failed to check installed apps:', e); } } + + if (isInstalled) { + const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true'; + if (!skipRedirect) { + this.showRedirectOverlay.set(true); + // Tentiamo il reindirizzamento automatico + setTimeout(() => { + window.location.href = this.protocolLink; + }, 800); + } + } else { + // Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale + const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true'; + if (!skipInstall && (this.settingsService.isAndroid() || this.settingsService.isIos())) { + this.showInstallOverlay.set(true); + } + } + } + + openPwaManual() { + window.location.href = this.protocolLink; + } + + stayInBrowser() { + sessionStorage.setItem('skip-pwa-redirect', 'true'); + this.showRedirectOverlay.set(false); + } + + closeInstallOverlay() { + sessionStorage.setItem('skip-pwa-install', 'true'); + this.showInstallOverlay.set(false); + } + + async triggerInstall() { + await this.settingsService.installPwa(); + this.closeInstallOverlay(); } } export function showFullscreenUpdateOverlay() { - const overlay = document.createElement('div'); - overlay.id = 'pwa-update-overlay'; - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100vw'; - overlay.style.height = '100vh'; - overlay.style.backgroundColor = '#121212'; - overlay.style.color = '#ffffff'; - overlay.style.display = 'flex'; - overlay.style.flexDirection = 'column'; - overlay.style.justifyContent = 'center'; - overlay.style.alignItems = 'center'; - overlay.style.zIndex = '99999'; - overlay.style.fontFamily = "'Outfit', sans-serif"; - overlay.style.transition = 'opacity 0.5s ease'; - - overlay.innerHTML = ` -
-
- CantiCristiani -
-

Download aggiornamento

-

Scaricamento della nuova versione...

-
Ricerca versione...
-
Fase: Download
-
-
-
-
0%
-
- `; - document.body.appendChild(overlay); + if ((window as any).PwaLoader) { + (window as any).PwaLoader.show(); + (window as any).PwaLoader.update({ + title: 'Download aggiornamento', + phase: 'Fase: Download', + desc: 'Scaricamento della nuova versione...', + percent: 0 + }); + } // Fetch remote version to display the version being downloaded fetch(`/version.json?cb=${Date.now()}`) @@ -149,38 +321,31 @@ export function showFullscreenUpdateOverlay() { throw new Error('Fallback'); }) .then(data => { - const versionEl = document.getElementById('pwa-update-version'); - if (data && data.version && versionEl) { - versionEl.textContent = 'Versione ' + data.version; + if (data && data.version && (window as any).PwaLoader) { + (window as any).PwaLoader.update({ + version: 'Versione ' + data.version + }); } }) - .catch(() => { - const versionEl = document.getElementById('pwa-update-version'); - if (versionEl) { - versionEl.textContent = ''; - } - }); + .catch(() => {}); let percent = 0; const interval = setInterval(() => { if (percent < 95) { percent += Math.floor(Math.random() * 5) + 2; if (percent > 95) percent = 95; - updatePercent(percent); + if ((window as any).PwaLoader) { + (window as any).PwaLoader.update({ percent }); + } } }, 150); - function updatePercent(val: number) { - const bar = document.getElementById('pwa-update-bar'); - const txt = document.getElementById('pwa-update-percent'); - if (bar) bar.style.width = val + '%'; - if (txt) txt.textContent = val + '%'; - } - return { finish: () => { clearInterval(interval); - updatePercent(100); + if ((window as any).PwaLoader) { + (window as any).PwaLoader.update({ percent: 100 }); + } } }; } diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 45c243b..3df667d 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -19,9 +19,6 @@ - - - @@ -48,6 +45,19 @@ {{ filteredCanti().length }}
+ + {{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }} + + + + + + +
- - {{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }} - - - - - - - - Non Validati + (click)="toggleNonValidati()" + style="display: inline-flex; align-items: center; gap: 6px;"> + Non Validati + +
@@ -213,28 +218,6 @@ (click)="onInteraction()">
- -
- -
@@ -440,20 +423,4 @@ - -
-
-
- Installa su iPhone - - - -
-
-

- Tocca il pulsante Condividi in basso e seleziona "Aggiungi a schermata Home". -

-
-
-
-
+ diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index d25bbb7..3ed1fd9 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -43,9 +43,6 @@ export class HomePage implements OnDestroy { public isAddingSongs = signal(false); public reorderList = signal([]); public limit = signal(30); - - public showAndroidBanner = signal(false); - public showIosTooltip = signal(false); public hasUpdateAvailable = signal(false); public fontSize = signal(1.0); @@ -77,6 +74,7 @@ export class HomePage implements OnDestroy { private firstInteraction = true; private updatePollInterval: any = null; + private queryParamsSubscription: any = null; async checkForAppUpdate(event?: Event) { if (event) event.stopPropagation(); @@ -117,14 +115,32 @@ export class HomePage implements OnDestroy { await registration.update(); } + let sub: any = null; + let readyPromise: Promise | undefined = undefined; + + if (this.swUpdate.isEnabled) { + readyPromise = new Promise((resolve) => { + sub = this.swUpdate.versionUpdates + .pipe( + filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), + first() + ) + .subscribe(() => { + resolve(); + }); + }); + } + if (this.swUpdate.isEnabled) { const swFoundUpdate = await this.swUpdate.checkForUpdate(); if (swFoundUpdate) { - await this.applyUpdateAndReload(); + await this.applyUpdateAndReload(readyPromise, sub); return true; } } + if (sub) sub.unsubscribe(); + const versionMismatch = await this.checkVersionJson(); if (versionMismatch) { await this.applyUpdateAndReload(); @@ -144,7 +160,7 @@ export class HomePage implements OnDestroy { } } - private async applyUpdateAndReload() { + private async applyUpdateAndReload(readyPromise?: Promise, subscription?: any) { const overlay = showFullscreenUpdateOverlay(); let activated = false; @@ -164,20 +180,19 @@ export class HomePage implements OnDestroy { }, 600); }; - if (this.swUpdate.isEnabled) { - this.swUpdate.versionUpdates - .pipe( - filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), - first() - ) - .subscribe(() => { - activateAndReload(); - }); + if (readyPromise) { + Promise.race([ + readyPromise, + new Promise((resolve) => setTimeout(resolve, 25000)) + ]).then(() => { + if (subscription) subscription.unsubscribe(); + activateAndReload(); + }); + } else { + setTimeout(() => { + activateAndReload(); + }, 1000); } - - setTimeout(() => { - activateAndReload(); - }, 6000); } private async checkVersionJson(): Promise { @@ -429,21 +444,7 @@ export class HomePage implements OnDestroy { 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'; - this.showAndroidBanner.set( - this.settingsService.isAndroid() && - !this.settingsService.isStandalone() && - !androidDismissed - ); - - this.showIosTooltip.set( - this.settingsService.isIos() && - !this.settingsService.isStandalone() && - !iosDismissed - ); // Track initial community filter state to avoid clearing during the initial run of the effect let prevFilterActive = this.comunitaService.isFilterActive(); @@ -482,17 +483,43 @@ export class HomePage implements OnDestroy { } }); - this.route.queryParams.subscribe(params => { - if (params['import']) { - this.handleImport(params['import']); + let queryParamsSubscribed = false; + effect(() => { + if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) { + queryParamsSubscribed = true; + this.queryParamsSubscription = this.route.queryParams.subscribe(params => { + 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']); + } + }); } - if (params['playlist-uid']) { - this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']); + }, { allowSignalWrites: true }); + + let hasAutoActivatedPlaylist = false; + effect(() => { + const lastPl = this.playlistService.lastPlaylist(); + const versionReady = this.settingsService.isVersionCheckComplete(); + + if (versionReady && lastPl && !hasAutoActivatedPlaylist) { + const params = this.route.snapshot.queryParams; + if (!params['id'] && !params['import'] && !params['playlist-uid'] && !params['restore-uid']) { + hasAutoActivatedPlaylist = true; + this.selectPlaylist(lastPl); + this.activeFilterType.set('playlist'); + if (lastPl.ids && lastPl.ids.length > 0) { + this.goToCanto(lastPl.ids[0]); + } + } else { + hasAutoActivatedPlaylist = true; + } } - if (params['restore-uid']) { - this.handleRemoteRestore(params['restore-uid']); - } - }); + }, { allowSignalWrites: true }); let prevSelectionMode = false; @@ -550,33 +577,7 @@ export class HomePage implements OnDestroy { }, { allowSignalWrites: true }); } - dismissAndroidBanner(event?: Event) { - if (event) event.stopPropagation(); - this.showAndroidBanner.set(false); - localStorage.setItem('pwa-android-dismissed', 'true'); - } - dismissIosTooltip(event?: Event) { - if (event) event.stopPropagation(); - this.showIosTooltip.set(false); - localStorage.setItem('pwa-ios-dismissed', 'true'); - } - - async installAndroidPwa() { - if (this.settingsService.deferredPrompt()) { - await this.settingsService.installPwa(); - this.dismissAndroidBanner(); - } else { - const toast = await this.toastCtrl.create({ - message: 'Tocca il menu del browser (i tre puntini in alto a destra) e seleziona "Aggiungi a schermata Home" o "Installa app".', - duration: 6000, - position: 'bottom', - color: 'secondary', - buttons: [{ text: 'OK', role: 'cancel' }] - }); - await toast.present(); - } - } async handleImport(base64: string) { try { @@ -631,7 +632,11 @@ export class HomePage implements OnDestroy { if (this.playlistService.processImportJson(json)) { this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali - this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge' }); + this.activeFilterType.set('playlist'); + await this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge', replaceUrl: true }); + if (json.ids && json.ids.length > 0) { + this.goToCanto(json.ids[0]); + } } } catch (e) { console.error('Failed to import playlist', e); @@ -651,6 +656,7 @@ export class HomePage implements OnDestroy { }); await loading.present(); + let hasNavigatedToCanto = false; try { const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' }); if (!response.ok) { @@ -712,6 +718,7 @@ export class HomePage implements OnDestroy { // Select it immediately this.selectPlaylist(selectedPl); + this.activeFilterType.set('playlist'); await loading.dismiss(); @@ -721,6 +728,12 @@ export class HomePage implements OnDestroy { color: 'success' }); await toast.present(); + + if (selectedPl.ids && selectedPl.ids.length > 0) { + hasNavigatedToCanto = true; + await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge', replaceUrl: true }); + this.goToCanto(selectedPl.ids[0]); + } } else { throw new Error('Formato dati non valido.'); } @@ -734,7 +747,9 @@ export class HomePage implements OnDestroy { }); await alert.present(); } finally { - this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge' }); + if (!hasNavigatedToCanto) { + this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge' }); + } } } @@ -839,6 +854,9 @@ export class HomePage implements OnDestroy { if (this.updatePollInterval) { clearInterval(this.updatePollInterval); } + if (this.queryParamsSubscription) { + this.queryParamsSubscription.unsubscribe(); + } } onSearch(event: any) { @@ -1408,6 +1426,12 @@ export class HomePage implements OnDestroy { // Automatically save to the device! this.playlistService.savePlaylist(playlistName, ids); + + // Activate the playlist filter and open the first song! + this.activeFilterType.set('playlist'); + if (ids.length > 0) { + this.goToCanto(ids[0]); + } } } diff --git a/src/app/pages/display/display.page.html b/src/app/pages/display/display.page.html index 062888e..774d3c8 100644 --- a/src/app/pages/display/display.page.html +++ b/src/app/pages/display/display.page.html @@ -8,7 +8,7 @@
- + {{ seg.chord }} {{ seg.text }} @@ -19,7 +19,7 @@
- + {{ seg.chord }} {{ seg.text }} @@ -30,7 +30,7 @@
- + {{ seg.chord }} {{ seg.text }} diff --git a/src/app/pages/display/display.page.ts b/src/app/pages/display/display.page.ts index 9e6de11..dda18e5 100644 --- a/src/app/pages/display/display.page.ts +++ b/src/app/pages/display/display.page.ts @@ -82,7 +82,7 @@ export class DisplayPage implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private cantiService = inject(CantiService); - private lyricsParser = inject(LyricsParserService); + public lyricsParser = inject(LyricsParserService); private comunitaService = inject(ComunitaService); constructor() { diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index 04a67b3..2a1f69d 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -11,14 +11,6 @@ {{ getCommunitySongNumber(canto()) }} {{ canto()?.titolo || 'Player' }} - - - - - - - -
@@ -26,10 +18,10 @@
- + - + -
- -
- - + + +
+ + prv - - +
+
+ + - - +
+ +
+ + + +
+
+ + nxt + +
+ + +
+ + + + + + +
+ + +
+ + + +
+ + V{{ autoscrollSpeed() }} +
+ +
@@ -86,8 +113,8 @@ [class.active]="isActiveLine(si, li)"> - - + + {{ seg.chord }} {{ seg.text }} @@ -99,7 +126,7 @@
- +
@@ -107,33 +134,36 @@ - - + +
- + - - - - - - + + + + + + + + + +
+ Nessun audio disponibile +
+
- - - - - +
@@ -167,6 +197,18 @@
+ +
+ + + + + + + + + +
@@ -182,38 +224,30 @@
- -
- - - - - - - - - -
-
- + - +
- -
- + +
+
+ +
+ +
+ diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index 02e40c8..eb0268f 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -172,6 +172,9 @@ color: rgba(255, 255, 255, 0.9); transition: all 0.3s ease; min-height: 1.5em; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; &.active { color: var(--ion-color-secondary); @@ -187,6 +190,10 @@ vertical-align: bottom; margin-right: 0.2em; + &.contiguous-next { + margin-right: 0 !important; + } + .chord { font-size: 0.75em; font-weight: 700; @@ -299,7 +306,6 @@ 100% { transform: scale(0.9); opacity: 0.6; } } -// Fullscreen and Orientation @media (orientation: landscape) { // Always hide footer in landscape as we have side controls ion-footer { @@ -309,22 +315,19 @@ .lyrics-container { height: 100%; padding-top: 4px; // Minimized + padding-bottom: 8px !important; padding-right: 90px !important; // More room for side controls and zoom } + .lyrics-view { + max-width: 100% !important; + } + ion-content { --offset-bottom: 0px !important; } } -ion-content.full-screen-content { - --offset-bottom: 0px !important; - - @media (orientation: portrait) { - --offset-bottom: 48px !important; // Footer height - } -} - .landscape-side-controls { display: none !important; // Strict hidden in portrait @@ -333,9 +336,9 @@ ion-content.full-screen-content { flex-direction: column; position: fixed; right: 0; - top: 44px !important; // Align with header + top: 56px !important; // Align below header bottom: 0; - width: 60px; + width: 50px; background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(10px); border-left: 1px solid rgba(255, 255, 255, 0.1); @@ -347,8 +350,10 @@ ion-content.full-screen-content { overflow-y: auto; display: flex; flex-direction: column; - padding: 12px 0; - gap: 16px; + justify-content: flex-start; + align-items: center; + padding: 8px 0; + gap: 12px; &::-webkit-scrollbar { display: none; } } @@ -356,22 +361,81 @@ ion-content.full-screen-content { display: flex; flex-direction: column; align-items: center; - gap: 10px; // Reduced for a more compact layout - padding-bottom: 20px; + gap: 8px; // Reduced for a more compact layout + padding-bottom: 12px; + background: rgba(255, 255, 255, 0.05); + margin: 0 2px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 6px; ion-button { --padding-start: 0; --padding-end: 0; margin: 0; - height: 48px; - ion-icon { font-size: 1.8rem; } + height: 38px; + width: 38px; + ion-icon { font-size: 1.5rem; } } } + .vertical-range-container { + height: 120px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + margin: 8px 0; + } + + .vertical-range { + transform: rotate(-90deg); + width: 120px; + --bar-height: 4px; + --knob-size: 14px; + padding: 0; + margin: 0; + } + + .side-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + font-size: 0.7rem; + font-weight: 700; + color: var(--ion-color-secondary); + padding: 4px 0; + cursor: pointer; + + ion-icon { + font-size: 1.0rem; + } + + &.tilted { + color: var(--ion-color-success, #2ed573); + text-shadow: 0 0 5px rgba(46, 213, 115, 0.6); + } + } + + .side-val { + font-size: 0.7rem; + font-weight: 700; + } + .side-divider { display: none; } } + +ion-content.full-screen-content { + --offset-bottom: 0px !important; + + @media (orientation: portrait) { + --offset-bottom: 48px !important; // Footer height + } +} .mic-sensitivity-overlay { position: fixed; @@ -673,6 +737,13 @@ ion-content.full-screen-content { backdrop-filter: none !important; } + .landscape-audio-player { + --background: #ffffff !important; + background: #ffffff !important; + border-left: 1px solid rgba(0, 0, 0, 0.2) !important; + backdrop-filter: none !important; + } + .slim-controls .group { background: rgba(0, 0, 0, 0.05) !important; border: 1px solid rgba(0, 0, 0, 0.15) !important; @@ -807,3 +878,34 @@ ion-content.full-screen-content { } } } + +.black-screen-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: #000000; + z-index: 99999; + cursor: pointer; +} + +.landscape-playlist-btn { + writing-mode: vertical-rl; + text-transform: uppercase; + font-weight: 700; + font-size: 0.65rem; + letter-spacing: 1px; + height: auto; + min-height: 75px; + width: 30px; + margin: 0; + --padding-start: 2px; + --padding-end: 2px; + color: var(--ion-color-secondary); + display: flex; + align-items: center; + justify-content: center; + transform: rotate(180deg); +} + diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index 634045b..4959655 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, untracked } from '@angular/core'; +import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { AlertController, ToastController, GestureController } from '@ionic/angular'; @@ -24,6 +24,24 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { public canto = signal(null); public activeSongId = signal(null); + @HostListener('window:keydown', ['$event']) + handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { + return; + } + if (event.key === 'ArrowUp') { + this.prevSong(); + event.preventDefault(); + } else if (event.key === 'ArrowDown') { + this.nextSong(); + event.preventDefault(); + } + } + + + public isLandscapeActive = signal(window.innerWidth > window.innerHeight); + /** true = show chords (accordi mode), false = text only */ public showChords = signal(false); @@ -44,7 +62,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { // For local songs, if accordi is missing but testo has chords, use parseAccordi on testo const hasChordsInText = !c.accordi && c.testo?.includes('['); - if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) { + // Force text-only mode in landscape + const activeShowChords = this.showChords() && !this.isLandscapeActive(); + + if ((activeShowChords && c.accordi) || (activeShowChords && hasChordsInText)) { sections = this.lyricsParser.parseAccordi(c.accordi || c.testo); } else { sections = this.lyricsParser.parseText(c.testo); @@ -68,7 +89,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } // Apply transposition if in chords mode - if (this.showChords()) { + if (activeShowChords) { return this.lyricsParser.transposeSections(sections, this.transposeAmount()); } @@ -88,6 +109,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { private readonly MAX_FONT = 5.0; private readonly FONT_STEP = 0.15; + public minZoom = computed(() => this.isLandscapeActive() ? 2.0 : this.MIN_FONT); + public maxZoom = computed(() => this.isLandscapeActive() ? 3.0 : this.MAX_FONT); + private initialPinchDistance: number | null = null; private initialFontSize: number = 1.0; @@ -104,7 +128,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { private route = inject(ActivatedRoute); public router = inject(Router); public cantiService = inject(CantiService); - private lyricsParser = inject(LyricsParserService); + public lyricsParser = inject(LyricsParserService); private alertCtrl = inject(AlertController); private toastCtrl = inject(ToastController); public themeService = inject(ThemeService); @@ -210,6 +234,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (scrollEl) { scrollEl.scrollTop = 0; } + this.checkLandscapeZoom(); }, 100); } } @@ -269,9 +294,147 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.autoscrollSpeed.set(2); } }, { allowSignalWrites: true }); + + // Fullscreen control in landscape mode + effect(() => { + const isLandscape = this.isLandscapeActive(); + + if (isLandscape) { + this.enterFullscreen(); + } else { + this.exitFullscreen(); + } + }); + } + + public isBlackScreen = signal(false); + + @HostListener('window:resize', ['$event']) + onResize(event: any) { + this.isLandscapeActive.set(window.innerWidth > window.innerHeight); + this.checkLandscapeZoom(); + } + + private isLandscape(): boolean { + return window.innerWidth > window.innerHeight; + } + + private checkAndLimitFontSize(targetFont: number): number { + const titleMain = this.el.nativeElement.querySelector('.title-main'); + if (!titleMain) return targetFont; + + const originalStyle = titleMain.style.fontSize; + let bestFont = targetFont; + + // We want to find the largest font <= targetFont where the title stays on one line. + // Let's iterate down from targetFont to 0.6 in steps of 0.05. + for (let f = targetFont; f >= 0.6; f -= 0.05) { + titleMain.style.fontSize = `${f * 1.1}rem`; + + // Force layout calculation + const height = titleMain.clientHeight; + // Get computed line height + const computedStyle = window.getComputedStyle(titleMain); + const lineHeightVal = computedStyle.lineHeight; + let lh = parseFloat(lineHeightVal); + + // If line-height is 'normal', fallback to a reasonable estimate based on font-size + if (isNaN(lh) || lineHeightVal === 'normal') { + const fs = parseFloat(computedStyle.fontSize) || (f * 1.1 * 16); + lh = fs * 1.25; + } + + // If the actual height is less than 1.5 * line-height, it fits on one line! + if (height <= lh * 1.5) { + bestFont = f; + break; + } + bestFont = f; // Fallback + } + + // Restore original style + titleMain.style.fontSize = originalStyle; + return Math.max(0.6, parseFloat(bestFont.toFixed(2))); + } + + public checkLandscapeZoom() { + if (this.isLandscape()) { + setTimeout(() => { + const container = this.el.nativeElement.querySelector('.lyrics-container'); + if (!container) return; + const visibleHeight = container.clientHeight; + const lineElems = this.el.nativeElement.querySelectorAll('.lyric-line'); + if (lineElems.length > 0 && visibleHeight > 0) { + let totalHeight = 0; + lineElems.forEach((el: any) => { + totalHeight += el.getBoundingClientRect().height; + }); + const avgLineHeight = totalHeight / lineElems.length; + if (avgLineHeight > 0) { + const currentFont = this.fontSize(); + const margin = 16; // 1rem in pixels + const targetLineHeight = Math.max(10, (visibleHeight / 3.1) - margin); + const avgLineHeightAtFont1 = avgLineHeight / currentFont; + const newFont = targetLineHeight / avgLineHeightAtFont1; + + const targetFont = Math.max(2.0, Math.min(3.0, newFont)); + let limitedFont = this.checkAndLimitFontSize(targetFont); + if (limitedFont < 2.0) { + limitedFont = 2.0; + } + this.fontSize.set(limitedFont); + this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); + } + } + }, 150); + } else { + const limitedFont = this.checkAndLimitFontSize(1.0); + this.fontSize.set(limitedFont); + this.channel.postMessage({ type: 'SYNC_FONT', fontSize: limitedFont }); + } + } + + deactivateBlackScreen() { + this.isBlackScreen.set(false); + } + + private async enterFullscreen() { + try { + const docEl = document.documentElement; + if (docEl.requestFullscreen) { + await docEl.requestFullscreen(); + } else if ((docEl as any).webkitRequestFullscreen) { + await (docEl as any).webkitRequestFullscreen(); + } else if ((docEl as any).msRequestFullscreen) { + await (docEl as any).msRequestFullscreen(); + } + } catch (e) { + console.warn('[PlayerPage] Failed to enter fullscreen:', e); + } + } + + private async exitFullscreen() { + try { + if (document.exitFullscreen) { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } + } else if ((document as any).webkitExitFullscreen) { + if ((document as any).webkitFullscreenElement) { + await (document as any).webkitExitFullscreen(); + } + } else if ((document as any).msExitFullscreen) { + if ((document as any).msFullscreenElement) { + await (document as any).msExitFullscreen(); + } + } + } catch (e) { + console.warn('[PlayerPage] Failed to exit fullscreen:', e); + } } ngAfterViewInit() { + this.checkLandscapeZoom(); const gestureX = this.gestureCtrl.create({ el: this.el.nativeElement, direction: 'x', @@ -291,6 +454,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } }); gestureX.enable(); + } @@ -345,9 +509,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { const currentDistance = this.getDistance(event.touches[0], event.touches[1]); const ratio = currentDistance / this.initialPinchDistance; let newSize = this.initialFontSize * ratio; - newSize = Math.max(this.MIN_FONT, Math.min(this.MAX_FONT, newSize)); - if (Math.abs(newSize - this.fontSize()) > 0.01) { - this.fontSize.set(newSize); + const minZ = this.minZoom(); + const maxZ = this.maxZoom(); + newSize = Math.max(minZ, Math.min(maxZ, newSize)); + let limited = this.checkAndLimitFontSize(newSize); + if (this.isLandscape() && limited < 2.0) { + limited = 2.0; + } + if (Math.abs(limited - this.fontSize()) > 0.01) { + this.fontSize.set(limited); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); } } @@ -358,6 +528,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.updatePlaylistSettings(); } + private getDistance(t1: Touch, t2: Touch): number { return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2)); } @@ -366,6 +537,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.showChords.update(v => !v); this.channel.postMessage({ type: 'SYNC_CHORDS', showChords: this.showChords() }); this.transposeAmount.set(0); + this.checkLandscapeZoom(); } private updatePlaylistSettings() { @@ -401,16 +573,26 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } 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(); + const maxZ = this.maxZoom(); + if (this.fontSize() < maxZ) { + const target = Math.min(this.fontSize() + this.FONT_STEP, maxZ); + let limited = this.checkAndLimitFontSize(target); + if (this.isLandscape() && limited < 2.0) { + limited = 2.0; + } + if (limited !== this.fontSize()) { + this.fontSize.set(limited); + this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); + this.updatePlaylistSettings(); + } } } zoomOut() { - if (this.fontSize() > this.MIN_FONT) { - this.fontSize.update(v => Math.max(v - this.FONT_STEP, this.MIN_FONT)); + const minZ = this.minZoom(); + if (this.fontSize() > minZ) { + const target = Math.max(this.fontSize() - this.FONT_STEP, minZ); + this.fontSize.set(target); this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() }); this.updatePlaylistSettings(); } @@ -442,7 +624,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.currentLineIndex.set(0); this.youtubePlayerService.seekTo(0); this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 }); - this.lastScrollBlock.set('center'); + this.lastScrollBlock.set('start'); + setTimeout(() => { + const scrollEl = this.el.nativeElement.querySelector('.lyrics-container'); + if (scrollEl) { + scrollEl.scrollTop = 0; + } + }, 100); } public calculatePageStepSize(): number { @@ -600,13 +788,16 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } if (foundNextIdx !== -1) { - console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx); - return foundNextIdx; + // Overlap by 1 line to ensure the last visible line is repeated at the top of the next page + const targetIdx = Math.max(currentIdx + 1, foundNextIdx - 1); + console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Nuova pagina inizierà con overlap a:', targetIdx); + return targetIdx; } - // Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard + // Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard con 1 riga di overlap const step = this.calculatePageStepSize(); - return Math.min(totalLines - 1, currentIdx + step); + const targetIdx = Math.max(currentIdx + 1, currentIdx + step - 1); + return Math.min(totalLines - 1, targetIdx); } catch (e) { console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e); return this.currentLineIndex() + 1; @@ -614,6 +805,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } next(isAutomatic: boolean = false, isVisual: boolean = false) { + if (this.isBlackScreen()) { + this.deactivateBlackScreen(); + return; + } this.lastAdvanceTimestamp = Date.now(); const totalLines = this.getTotalLines(); @@ -638,7 +833,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { nextIdx = this.calculateNextPageStartIndex(); } this.lastScrollBlock.set('center'); - } else if (this.settingsService.karaokePageScrollMode() || isVisual) { + } else if ((this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive()) { // Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte nextIdx = this.calculateNextPageStartIndex(); this.lastScrollBlock.set('start'); @@ -656,11 +851,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { // Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti prev(isVisual: boolean = false) { + if (this.isBlackScreen()) { + this.deactivateBlackScreen(); + return; + } this.lastAdvanceTimestamp = Date.now(); const prevIdx = this.currentLineIndex(); if (prevIdx > 0) { - const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual; + const isPageMode = (this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive(); const stepSize = isPageMode ? this.calculatePageStepSize() : 1; const nextIdx = Math.max(0, prevIdx - stepSize); @@ -690,6 +889,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { const index = list.indexOf(currentId); if (index >= 0 && index < list.length - 1) { const nextId = list[index + 1]; + if (this.isLandscapeActive()) { + this.isBlackScreen.set(true); + } this.router.navigate(['/player'], { queryParams: { id: nextId } }); } else { this.playlistService.autoPlayPlaylist.set(false); @@ -708,6 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { const index = list.indexOf(currentId); if (index > 0) { const prevId = list[index - 1]; + if (this.isLandscapeActive()) { + this.isBlackScreen.set(true); + } this.router.navigate(['/player'], { queryParams: { id: prevId } }); } } @@ -949,6 +1154,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy() { + this.exitFullscreen(); this.stopAutoscroll(); this.logPreviousSongTime(); this.stopCameraNavigation(); diff --git a/src/app/pages/propose-canto/propose-canto.page.html b/src/app/pages/propose-canto/propose-canto.page.html index d72d153..2be6265 100644 --- a/src/app/pages/propose-canto/propose-canto.page.html +++ b/src/app/pages/propose-canto/propose-canto.page.html @@ -31,107 +31,159 @@
- - - Titolo del Canto - - +
+ +
+ + + Titolo del Canto + + -
- - Momento Liturgico - - - {{ lit.tag_name }} - - - +
+ + Momento Liturgico + + + {{ lit.tag_name }} + + + - - Periodo / Tema - - - {{ tem.tag_name }} - - - -
- - -
-
- - {{ tag.label }} - -
-
- - -
-
- - {{ group.root }} - -
-
- - -
-
- Variazioni {{ selectedRootChord }}: - - {{ chord }} - -
-
- - -
-
-
- Editor Testo + + Periodo / Tema + + + {{ tem.tag_name }} + + +
-
- - - - - + + +
+
+ + {{ tag.label }} + +
+
+ + +
+
+ + {{ group.root }} + +
+
+ + +
+
+ Variazioni {{ selectedRootChord }}: + + {{ chord }} + +
+
+ + +
+
+
+ Editor Testo +
+
+ + + + + + +
+
+ + + + +
+ + + Autore / Link YouTube + + + + + +
+ + + Salva nei Miei Canti + +
+
+ + +
+
+
+ Anteprima Attiva + + + {{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
+
+
+

{{ title || 'Titolo del Canto' }}

+

{{ author }}

+
+
+
+ + + + +
+ + + + {{ seg.chord }} + {{ seg.text }} + + + + + {{ line.text }} + +
+
+ +
+ Il testo formattato apparirà qui mentre scrivi... +
+
+
- - - -
- - - Autore / Link YouTube - - - - - -
- - - Salva nei Miei Canti -
diff --git a/src/app/pages/propose-canto/propose-canto.page.scss b/src/app/pages/propose-canto/propose-canto.page.scss index d43f8b9..d5ac634 100644 --- a/src/app/pages/propose-canto/propose-canto.page.scss +++ b/src/app/pages/propose-canto/propose-canto.page.scss @@ -113,7 +113,8 @@ body.high-contrast :host ::ng-deep { } .propose-container { - max-width: 800px; + max-width: 1400px; + width: 100%; margin: 0 auto; } @@ -470,3 +471,233 @@ body.high-contrast :host ::ng-deep { } } } + +/* RESPONSIVE LAYOUT & ACTIVE PREVIEW PANE */ +.editor-preview-split { + display: grid; + grid-template-columns: 1fr; + gap: 24px; + align-items: flex-start; + + @media (min-width: 992px) { + grid-template-columns: 1fr 1fr; + } +} + +.preview-column { + @media (min-width: 992px) { + position: sticky; + top: 16px; + } +} + +.preview-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(10px); + + .preview-header { + background: rgba(255, 255, 255, 0.04); + padding: 10px 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + + .preview-title { + font-size: 11px; + font-weight: 800; + color: var(--ion-color-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .preview-toggle-btn { + --color: rgba(255, 255, 255, 0.7); + margin: 0; + font-size: 11px; + font-weight: 600; + + ion-icon { + font-size: 14px; + } + } + } + + .preview-body { + padding: 24px; + min-height: 400px; + background: rgba(0, 0, 0, 0.2); + + .preview-song-header { + margin-bottom: 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 14px; + + .preview-song-title { + font-size: 1.6rem; + font-weight: 700; + color: #ffffff; + margin: 0 0 6px 0; + font-family: 'Outfit', sans-serif; + } + + .preview-song-author { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.5); + margin: 0; + font-weight: 500; + } + } + + .preview-lyrics-container { + font-size: 1.1rem; + font-family: 'Outfit', sans-serif; + overflow-y: auto; + max-height: 60vh; + padding-right: 8px; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + } + + .preview-section { + margin-bottom: 1.8rem; + position: relative; + + &.chorus { + background: rgba(var(--ion-color-secondary-rgb), 0.03); + border-left: 3px solid var(--ion-color-secondary); + padding-left: 1rem; + margin-left: -1rem; + border-radius: 0 8px 8px 0; + } + + .preview-section-label { + font-size: 0.7rem; + text-transform: uppercase; + font-weight: 700; + color: var(--ion-color-secondary); + margin-bottom: 0.4rem; + opacity: 0.7; + letter-spacing: 1px; + } + } + + .preview-lyric-line { + margin-bottom: 0.8rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.9); + min-height: 1.5em; + } + + .preview-chord-segment { + display: inline-flex; + flex-direction: column; + vertical-align: bottom; + margin-right: 0.25em; + + &.contiguous-next { + margin-right: 0 !important; + } + + .preview-chord { + font-size: 0.78em; + font-weight: 700; + color: var(--ion-color-secondary); + height: 1.25em; + margin-bottom: -0.2em; + } + + .preview-seg-text { + white-space: pre; + &::after { + content: '\200b'; + } + } + } + + .preview-empty { + color: rgba(255, 255, 255, 0.3); + text-align: center; + padding-top: 80px; + font-style: italic; + font-size: 0.95rem; + } + } + } + + /* High Contrast mode overrides */ + &.hc { + background: #ffffff; + border: 2px solid #000000; + box-shadow: none; + + .preview-header { + background: #f0f0f0; + border-bottom: 2px solid #000000; + + .preview-title { + color: #000000; + } + + .preview-toggle-btn { + --color: #000000; + font-weight: 700; + } + } + + .preview-body { + background: #ffffff; + + .preview-song-header { + border-bottom: 2px solid #000000; + + .preview-song-title { + color: #000000; + } + + .preview-song-author { + color: #333333; + } + } + + .preview-lyrics-container { + .preview-section { + &.chorus { + background: #f5f5f5; + border-left: 3px solid #000000; + } + + .preview-section-label { + color: #000000; + } + } + + .preview-lyric-line { + color: #000000; + } + + .preview-chord-segment { + .preview-chord { + color: #000000; + text-decoration: underline; + font-weight: 800; + } + } + + .preview-empty { + color: #666666; + } + } + } + } +} diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index 3ca3536..c1ff722 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -8,6 +8,7 @@ import { MyCantiService } from '../../services/my-canti.service'; import { PlaylistService } from '../../services/playlist.service'; import { ThemeService } from '../../services/theme.service'; import { ActivatedRoute, RouterModule, Router } from '@angular/router'; +import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service'; @Component({ selector: 'app-propose-canto', @@ -27,6 +28,17 @@ export class ProposeCantoPage implements OnInit { public themeService = inject(ThemeService); private route = inject(ActivatedRoute); private router = inject(Router); + public lyricsParser = inject(LyricsParserService); + + showChordsPreview: boolean = true; + + get parsedSections(): ParsedSection[] { + return this.lyricsParser.parseAccordi(this.content); + } + + toggleChordsPreview() { + this.showChordsPreview = !this.showChordsPreview; + } title: string = ''; author: string = ''; diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index 42a2be6..a3267df 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -62,14 +62,7 @@ - - - -

Nascondi Barre (Player)

-

Modalità immersiva nel dettaglio canto

-
- -
+ diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index 3c633f0..4a32a7a 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -347,15 +347,33 @@ export class SettingsPage { await registration.update(); } + let sub: any = null; + let readyPromise: Promise | undefined = undefined; + + if (this.swUpdate.isEnabled) { + readyPromise = new Promise((resolve) => { + sub = this.swUpdate.versionUpdates + .pipe( + filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), + first() + ) + .subscribe(() => { + resolve(); + }); + }); + } + // Layer 2: Ask Angular SW to check if (this.swUpdate.isEnabled) { const swFoundUpdate = await this.swUpdate.checkForUpdate(); if (swFoundUpdate) { - await this.applyUpdateAndReload(); + await this.applyUpdateAndReload(readyPromise, sub); return true; } } + if (sub) sub.unsubscribe(); + // Layer 3: Fallback — check version.json const versionMismatch = await this.checkVersionJson(); if (versionMismatch) { @@ -377,7 +395,7 @@ export class SettingsPage { } } - private async applyUpdateAndReload() { + private async applyUpdateAndReload(readyPromise?: Promise, subscription?: any) { const overlay = showFullscreenUpdateOverlay(); let activated = false; @@ -397,24 +415,19 @@ export class SettingsPage { }, 600); }; - // Listen for VERSION_READY + activate + reload - if (this.swUpdate.isEnabled) { - this.swUpdate.versionUpdates - .pipe( - filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), - first() - ) - .subscribe(() => { - console.log('[PWA-Update] Settings: version ready, activating...'); - activateAndReload(); - }); + if (readyPromise) { + Promise.race([ + readyPromise, + new Promise((resolve) => setTimeout(resolve, 25000)) + ]).then(() => { + if (subscription) subscription.unsubscribe(); + activateAndReload(); + }); + } else { + setTimeout(() => { + activateAndReload(); + }, 1000); } - - // Safety timeout: reload after 6s regardless - setTimeout(() => { - console.log('[PWA-Update] Settings: safety timeout reached, activating...'); - activateAndReload(); - }, 6000); } private async checkVersionJson(): Promise { diff --git a/src/app/services/canti.service.ts b/src/app/services/canti.service.ts index 6c93230..afb7ead 100644 --- a/src/app/services/canti.service.ts +++ b/src/app/services/canti.service.ts @@ -1,6 +1,7 @@ import { Injectable, signal, inject } from '@angular/core'; import { HttpClient, HttpEventType } from '@angular/common/http'; import { Storage } from '@ionic/storage-angular'; +import { SettingsService } from './settings.service'; export interface Canto { id: string; @@ -34,6 +35,7 @@ export interface CantoEseguito { export class CantiService { private http = inject(HttpClient); private storage = inject(Storage); + private settingsService = inject(SettingsService); private _storage: Storage | null = null; public canti = signal([]); @@ -58,6 +60,20 @@ export class CantiService { await this.loadFromStorage(); if (this.canti() && this.canti().length > 0) { this.firstLoadCompleted.set(true); + if (this.settingsService.isVersionCheckComplete() && (window as any).PwaLoader) { + (window as any).PwaLoader.hide(); + } + } else { + // First boot or data cleared: show setup loader immediately + if ((window as any).PwaLoader) { + (window as any).PwaLoader.show(); + (window as any).PwaLoader.update({ + title: 'Setup in corso', + phase: 'Fase: Setup', + desc: 'Configurazione iniziale e caricamento canti...', + percent: 0 + }); + } } this.refresh(); } @@ -85,10 +101,16 @@ export class CantiService { }).subscribe({ next: async (event: any) => { if (event.type === HttpEventType.DownloadProgress) { + let pct = 0; if (event.total) { - this.progress.set(Math.round((event.loaded / event.total) * 100)); + pct = Math.round((event.loaded / event.total) * 100); + this.progress.set(pct); } else { this.progress.update(p => p < 90 ? p + 5 : p); + pct = this.progress(); + } + if (!this.firstLoadCompleted() && (window as any).PwaLoader) { + (window as any).PwaLoader.update({ percent: pct }); } } else if (event.type === HttpEventType.Response) { const response = event.body; @@ -137,12 +159,19 @@ export class CantiService { this.progress.set(100); this.loading.set(false); this.firstLoadCompleted.set(true); + + if (this.settingsService.isVersionCheckComplete() && (window as any).PwaLoader) { + (window as any).PwaLoader.hide(); + } } }, error: (error) => { console.error('Failed to fetch canti', error); this.loading.set(false); this.firstLoadCompleted.set(true); + if ((window as any).PwaLoader) { + (window as any).PwaLoader.hide(); + } } }); } diff --git a/src/app/services/lyrics-parser.service.ts b/src/app/services/lyrics-parser.service.ts index 5a930ce..5af9c5b 100644 --- a/src/app/services/lyrics-parser.service.ts +++ b/src/app/services/lyrics-parser.service.ts @@ -149,7 +149,7 @@ export class LyricsParserService { } // Add the chord as a new segment (text will be filled by next text chunk) - segments.push({ chord: match[1], text: '' }); + segments.push({ chord: this.transposeChord(match[1], 0), text: '' }); lastIndex = match.index + match[0].length; } @@ -197,7 +197,7 @@ export class LyricsParserService { const upperChord = chord.toUpperCase(); for (const r of possibleRoots) { - if (upperChord.startsWith(r)) { + if (upperChord.startsWith(r.toUpperCase())) { root = r; suffix = chord.substring(r.length); break; @@ -206,8 +206,11 @@ export class LyricsParserService { if (!root) return chord; - let index = this.scale.indexOf(root); - if (index === -1) index = this.flatScale.indexOf(root); + const upperRoot = root.toUpperCase(); + let index = this.scale.indexOf(upperRoot); + if (index === -1) { + index = this.flatScale.findIndex(n => n.toUpperCase() === upperRoot); + } if (index === -1) return chord; let newIndex = (index + semitones) % 12; @@ -231,7 +234,12 @@ export class LyricsParserService { } } - const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex]; + let newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex]; + if (newRoot === 'LA#') { + newRoot = 'SIb'; + } else if (newRoot === 'RE#') { + newRoot = 'MIb'; + } return newRoot + suffix; } @@ -251,4 +259,35 @@ export class LyricsParserService { })) })); } + + /** + * Helper to check if the segment at the given index is contiguous with the next segment. + * This means the text is split within a word (e.g. by a chord tag like "rima[RE]ne"). + */ + isContiguousNext(segments: ChordSegment[], index: number): boolean { + if (!segments || index >= segments.length - 1) return false; + + // Find the next segment with non-empty text + let nextWithText: ChordSegment | null = null; + for (let i = index + 1; i < segments.length; i++) { + if (segments[i].text && segments[i].text.length > 0) { + nextWithText = segments[i]; + break; + } + } + + const currentText = segments[index].text || ''; + if (!currentText) { + // If current segment has no text, it should have no margin-right to not add extra spacing + return true; + } + + if (!nextWithText) return false; + + const endsWithNonSpace = !/\s$/.test(currentText); + const startsWithNonSpace = !/^\s/.test(nextWithText.text); + + return endsWithNonSpace && startsWithNonSpace; + } } + diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index c991d88..def04a3 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -64,6 +64,7 @@ export class SettingsService { public isStandalone = signal(false); public isIos = signal(false); public isAndroid = signal(false); + public isVersionCheckComplete = signal(false); constructor() { // Gestione/Generazione ID utente univoco diff --git a/src/app/services/youtube-player.service.ts b/src/app/services/youtube-player.service.ts index 5ca3b67..3ebaa37 100644 --- a/src/app/services/youtube-player.service.ts +++ b/src/app/services/youtube-player.service.ts @@ -22,12 +22,6 @@ export class YoutubePlayerService { if (!this.connectivityService.isOnline()) { return false; } - // Check if iPhone/iPad/iPod - const userAgent = window.navigator.userAgent.toLowerCase(); - const isIOS = /iphone|ipad|ipod/.test(userAgent); - if (isIOS) { - return false; - } return true; }); diff --git a/src/app/version.ts b/src/app/version.ts index 2a45f40..ef20d01 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.06.12.1712'; +export const VERSION = '2026.06.14.1936'; diff --git a/src/index.html b/src/index.html index 380f9d0..56ad07e 100644 --- a/src/index.html +++ b/src/index.html @@ -34,77 +34,155 @@ - -
-
-
- CantiCristiani -
-

Avvio in corso

-

Caricamento dei componenti dell'applicazione...

-
Ricerca versione...
-
Fase: Avvio
-
-
-
-
0%
+
+
+
+ CantiCristiani
+

Avvio in corso

+

Caricamento dei componenti dell'applicazione...

+
Ricerca versione...
+
Fase: Avvio
+
+
+
+
0%
- - +
+ + + +