fix(pwa): progress bar setup loading, PWA install detection, Miei filter chip & UI cleanup
This commit is contained in:
@@ -59,3 +59,9 @@ echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||
# --- Upload ---
|
||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||
|
||||
# --- Allineamento Cantiletture JSON ---
|
||||
if [ -f "./allinealetture.sh" ]; then
|
||||
echo "🔄 Sincronizzazione cantiletture.json su server..."
|
||||
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
|
||||
fi
|
||||
|
||||
+82
-66
@@ -304,30 +304,70 @@ export class AppComponent implements OnInit {
|
||||
console.log('[AppComponent] PWA appinstalled event caught.');
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
this.isPwaInstalled.set(true);
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(true);
|
||||
this.showInstallOverlay.set(false);
|
||||
this.showRedirectOverlay.set(false);
|
||||
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Chiudi il browser',
|
||||
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
|
||||
isRedirect: true
|
||||
});
|
||||
}
|
||||
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
console.log('[AppComponent] Running on localhost - skipping protocol link redirect on appinstalled.');
|
||||
// Se la finestra è GIÀ stata trasformata in PWA standalone (es. Desktop Mac/Windows)
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
|
||||
if (isStandalone) {
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(false);
|
||||
this.checkLoaderDismissal();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = this.protocolLink;
|
||||
}, 1000);
|
||||
// Su Android, l'evento 'appinstalled' scatta in 2-3 secondi, MA l'OS impiega 10-12s
|
||||
// per pacchettizzare e registrare il WebAPK nella schermata Home.
|
||||
// Calibriamo la progress bar spalmata su ~11 secondi reali prima del reindirizzamento.
|
||||
this.isInstalling.set(true);
|
||||
this.isRedirecting.set(false);
|
||||
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Installazione applicazione',
|
||||
phase: 'Fase: Registrazione',
|
||||
desc: 'Generazione e registrazione dell\'applicazione sul dispositivo in corso...',
|
||||
percent: 15
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const TARGET_DURATION_MS = 11000; // 11 secondi reali per completare l'installazione WebAPK
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
let pct = Math.min(100, Math.round(15 + (elapsed / TARGET_DURATION_MS) * 85));
|
||||
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({ percent: pct });
|
||||
}
|
||||
|
||||
if (pct >= 100) {
|
||||
clearInterval(timer);
|
||||
this.isInstalling.set(false);
|
||||
this.isRedirecting.set(true);
|
||||
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({
|
||||
title: 'Chiudi il browser',
|
||||
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
|
||||
isRedirect: true
|
||||
});
|
||||
}
|
||||
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
console.log('[AppComponent] Running on localhost - skipping protocol link redirect on appinstalled.');
|
||||
this.isRedirecting.set(false);
|
||||
this.checkLoaderDismissal();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = this.protocolLink;
|
||||
}, 1000);
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
this.checkAndRedirectToPwa();
|
||||
@@ -375,9 +415,8 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
|
||||
async checkVersionSync(): Promise<boolean> {
|
||||
// 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.
|
||||
// Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
|
||||
// Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
|
||||
try {
|
||||
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
|
||||
const response = await Promise.race([
|
||||
@@ -387,10 +426,9 @@ export class AppComponent implements OnInit {
|
||||
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();
|
||||
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Avvio aggiornamento automatico...`);
|
||||
|
||||
// Prova ad attivare tramite SwUpdate se abilitato (scarica il nuovo bundle SW)
|
||||
// Scarica e attiva il nuovo Service Worker se abilitato
|
||||
if (this.swUpdate.isEnabled) {
|
||||
try {
|
||||
const hasSwUpdate = await Promise.race([
|
||||
@@ -398,7 +436,7 @@ export class AppComponent implements OnInit {
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
||||
]);
|
||||
if (hasSwUpdate) {
|
||||
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione...');
|
||||
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...');
|
||||
await Promise.race([
|
||||
this.swUpdate.activateUpdate(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5000))
|
||||
@@ -409,7 +447,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// Aggiorna anche la registrazione SW direttamente (doppia sicurezza)
|
||||
// Forziamo il controllo di aggiornamento della registrazione Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
@@ -419,7 +457,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// Disattiva service worker attivi per forzare il refresh completo
|
||||
// Deregistra i vecchi Service Worker per applicare la nuova versione pulita
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const registration of registrations) {
|
||||
@@ -427,7 +465,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// Cancella le cache del browser
|
||||
// Pulisci le cache del browser
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
@@ -435,9 +473,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
overlay.finish();
|
||||
|
||||
// Ricarica con parametro cache-busting per forzare l'allineamento remoto
|
||||
// Ricarica la pagina in modo trasparente
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('update_cb', Date.now().toString());
|
||||
window.location.replace(url.toString());
|
||||
@@ -450,13 +486,12 @@ export class AppComponent implements OnInit {
|
||||
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
|
||||
// Fallback: Controlla direttamente tramite SwUpdate se non intercettato da version.json
|
||||
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;
|
||||
@@ -467,7 +502,6 @@ export class AppComponent implements OnInit {
|
||||
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) {
|
||||
@@ -481,43 +515,36 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('update_cb', Date.now().toString());
|
||||
window.location.replace(url.toString());
|
||||
};
|
||||
|
||||
// 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');
|
||||
console.log('[PWA-Update] VERSION_READY ricevuto all\'avvio, ricaricamento automatico...');
|
||||
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<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
||||
]);
|
||||
|
||||
if (hasUpdate) {
|
||||
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Avvio download...');
|
||||
overlay = showFullscreenUpdateOverlay();
|
||||
console.log('[PWA-Update] Aggiornamento rilevato via SwUpdate. Applicazione automatica...');
|
||||
|
||||
// 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);
|
||||
}, 15000);
|
||||
|
||||
return true; // Attendi il ricaricamento
|
||||
return true;
|
||||
} else {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
@@ -546,34 +573,23 @@ export class AppComponent implements OnInit {
|
||||
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) {
|
||||
let isInstalled = false;
|
||||
if ('getInstalledRelatedApps' in navigator) {
|
||||
try {
|
||||
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
||||
isInstalled = relatedApps.length > 0;
|
||||
if (isInstalled) {
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to check installed apps:', e);
|
||||
}
|
||||
} else {
|
||||
isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
||||
}
|
||||
|
||||
// Se non è rilevata in localStorage/relatedApps ed è Android o Desktop con supporto ai prompt:
|
||||
// attendiamo 1.5s per dare tempo all'evento 'beforeinstallprompt' di scattare.
|
||||
// Se non scatta, significa che l'app è già installata.
|
||||
if (!isInstalled && !this.settingsService.isIos() && ('onbeforeinstallprompt' in window)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
||||
if (!isInstalled) {
|
||||
const hasPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
||||
if (!hasPrompt) {
|
||||
console.log('[AppComponent] PWA detected as already installed (onbeforeinstallprompt supported but no prompt fired).');
|
||||
isInstalled = true;
|
||||
localStorage.setItem('pwa-installed', 'true');
|
||||
}
|
||||
}
|
||||
// Se prima era salvata come installata ma l'utente riceve il prima possibile un beforeinstallprompt,
|
||||
// significa che l'app è stata disinstallata!
|
||||
if (this.settingsService.deferredPrompt() || this.settingsService.showInstallButton()) {
|
||||
isInstalled = false;
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
}
|
||||
|
||||
this.isPwaInstalled.set(isInstalled);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<ion-header class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient">
|
||||
<ion-title class="outfit-font">Condividi Playlist</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="bg-gradient ion-padding">
|
||||
<div class="qr-container">
|
||||
<p class="description outfit-font">
|
||||
Fai scansionare questo QR Code da un altro dispositivo per condividere all'istante la playlist <strong>{{ playlistName }}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="qr-card glass">
|
||||
<div class="qr-wrapper" *ngIf="qrCodeUrl">
|
||||
<img [src]="qrCodeUrl" alt="QR Code della Playlist" class="qr-image" />
|
||||
</div>
|
||||
|
||||
<div class="playlist-box">
|
||||
<span class="playlist-label outfit-font">Nome Playlist:</span>
|
||||
<span class="playlist-text outfit-font">{{ playlistName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-wrapper">
|
||||
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="shareLinkNative()">
|
||||
<ion-icon name="share-social-outline" slot="start"></ion-icon>
|
||||
Invia Link / Condividi
|
||||
</ion-button>
|
||||
|
||||
<ion-button expand="block" fill="outline" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
|
||||
<ion-icon name="copy-outline" slot="start"></ion-icon>
|
||||
Copia Link
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,90 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-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);
|
||||
}
|
||||
|
||||
.playlist-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ion-color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playlist-text {
|
||||
font-size: 1rem;
|
||||
color: var(--ion-text-color);
|
||||
font-weight: bold;
|
||||
word-break: break-word;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.actions-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin: 0;
|
||||
--border-radius: 14px;
|
||||
font-weight: 600;
|
||||
height: 48px;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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-share-playlist-qr-modal',
|
||||
templateUrl: './share-playlist-qr-modal.component.html',
|
||||
styleUrls: ['./share-playlist-qr-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule]
|
||||
})
|
||||
export class SharePlaylistQrModalComponent implements OnInit {
|
||||
private modalCtrl = inject(ModalController);
|
||||
private toastCtrl = inject(ToastController);
|
||||
|
||||
@Input() playlistName!: string;
|
||||
@Input() shareLink!: string;
|
||||
|
||||
public qrCodeUrl: string = '';
|
||||
|
||||
ngOnInit() {
|
||||
this.generateQr();
|
||||
}
|
||||
|
||||
async generateQr() {
|
||||
try {
|
||||
this.qrCodeUrl = await QRCode.toDataURL(this.shareLink, {
|
||||
errorCorrectionLevel: 'H',
|
||||
margin: 2,
|
||||
width: 400,
|
||||
color: {
|
||||
dark: '#1e293b',
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to generate QR Code:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async shareLinkNative() {
|
||||
const fileName = `${this.playlistName.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||
try {
|
||||
const res = await fetch(this.qrCodeUrl);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Playlist CantiCristiani',
|
||||
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla subito: ${this.shareLink}`
|
||||
});
|
||||
} else if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: 'Playlist CantiCristiani',
|
||||
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla: ${this.shareLink}`
|
||||
});
|
||||
} else {
|
||||
await this.copyToClipboard();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Share failed', err);
|
||||
await this.copyToClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
async copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.shareLink);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Link 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();
|
||||
}
|
||||
}
|
||||
+311
-159
@@ -6,12 +6,15 @@
|
||||
<div class="header-text-group">
|
||||
<span class="app-name">{{ appName }}</span>
|
||||
<span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
|
||||
v{{ version }}<span *ngIf="settingsService.userName()"> - {{ settingsService.userName() }}</span>
|
||||
v{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto">
|
||||
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
|
||||
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
|
||||
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
|
||||
@@ -35,117 +38,328 @@
|
||||
placeholder="Cerca un canto..."
|
||||
[value]="searchQuery()"
|
||||
(ionInput)="onSearch($event)"
|
||||
[disabled]="isAdvancedSearchOpen()"
|
||||
class="custom-searchbar">
|
||||
</ion-searchbar>
|
||||
<ion-button fill="clear" (click)="toggleVoiceSearch()" class="voice-search-btn">
|
||||
<ion-icon slot="icon-only" [name]="audioEngine.isSearching() ? 'mic' : 'mic-outline'" [color]="audioEngine.isSearching() ? 'danger' : 'secondary'"></ion-icon>
|
||||
<ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
|
||||
<ion-icon slot="icon-only" name="options-outline" [color]="isAdvancedSearchOpen() ? 'secondary' : 'medium'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="toggleVoiceSearch('global')" class="voice-search-btn" [disabled]="isAdvancedSearchOpen()">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions-row">
|
||||
<div class="song-count-card outfit-font">
|
||||
{{ filteredCanti().length }}
|
||||
</div>
|
||||
<div *ngIf="playlistService.activeListName() !== null && totalPlaylistDuration()" class="song-count-card outfit-font">
|
||||
<ion-icon name="time-outline" style="margin-right: 4px; font-size: 1rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
<span>{{ totalPlaylistDuration() }}</span>
|
||||
</div>
|
||||
<div class="filter-buttons">
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('playlist')"
|
||||
class="filter-chip">
|
||||
{{ playlistService.activeListName() !== null ? 'Playlist: ' + playlistService.activeListName() : 'Playlist' }}
|
||||
<span *ngIf="isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 20px; height: 20px; box-shadow: 0 0 8px rgba(var(--ion-color-secondary-rgb), 0.3); vertical-align: middle;">
|
||||
<ion-icon name="refresh-outline" style="font-size: 0.95rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
|
||||
</span>
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="playlistService.activeListName() === null"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="playlistService.activeListName() !== null" (click)="clearSpecialList($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; margin-right: 8px; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
||||
<ion-button
|
||||
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleComunitaFilter()"
|
||||
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
|
||||
class="filter-chip"
|
||||
style="margin: 0;">
|
||||
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
|
||||
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
||||
</ion-button>
|
||||
<!-- Advanced Search & Filters Toggle Row -->
|
||||
<div class="advanced-search-toggle-row">
|
||||
<div class="toggle-row-left">
|
||||
<div class="song-count-card outfit-font">
|
||||
{{ filteredCanti().length }}
|
||||
</div>
|
||||
<button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
|
||||
<ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
|
||||
<span>Ricerche</span>
|
||||
<span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="advanced-search-link outfit-font" (click)="toggleFilterCard()">
|
||||
<ion-icon [name]="isFilterCardOpen() ? 'chevron-up-outline' : 'filter-outline'"></ion-icon>
|
||||
<span>Filtri</span>
|
||||
<span class="inline-clear-btn" *ngIf="hasFilterCardParams()" (click)="clearFilterCard($event)" title="Reset filtri">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="advanced-search-link outfit-font" (click)="togglePlaylistCard()">
|
||||
<ion-icon [name]="isPlaylistCardOpen() ? 'chevron-up-outline' : 'list-outline'"></ion-icon>
|
||||
<span>Liste</span>
|
||||
<span class="inline-clear-btn" *ngIf="hasPlaylistCardParams()" (click)="clearPlaylistCard($event)" title="Reset liste">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Panel -->
|
||||
<div class="advanced-search-panel glass outfit-font"
|
||||
*ngIf="isAdvancedSearchOpen()"
|
||||
(touchstart)="onPanelTouchStart($event)"
|
||||
(touchend)="onPanelTouchEnd($event, 'advancedSearch')">
|
||||
<div class="adv-search-grid">
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per titolo</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="text-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titolo canto..."
|
||||
[value]="searchTitle()"
|
||||
(input)="onSearchTitleInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchTitle()" (click)="searchTitle.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('title')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'lista_completa' || showValidati() || showNonValidati() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('lista_completa')"
|
||||
class="filter-chip">
|
||||
{{ showValidati() ? 'Lista: Validati' : (showNonValidati() ? 'Lista: Non Validati' : 'Lista completa') }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="!showValidati() && !showNonValidati()"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="showValidati() || showNonValidati()" (click)="clearListaCompleta($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'liturgico' || selectedLiturgico() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('liturgico')"
|
||||
class="filter-chip">
|
||||
{{ selectedLiturgico() !== null ? 'Liturgia: ' + getSelectedLiturgicoLabel() : 'Liturgia' }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedLiturgico() === null"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() !== null" (click)="clearLiturgico($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'tematico' || selectedTematico() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleFilterType('tematico')"
|
||||
class="filter-chip">
|
||||
{{ selectedTematico() !== null ? 'Periodo: ' + getSelectedTematicoLabel() : 'Periodo' }}
|
||||
<ion-icon slot="end" name="chevron-down-outline" *ngIf="selectedTematico() === null"></ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="selectedTematico() !== null" (click)="clearTematico($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showSuggeriti() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleSuggeriti()"
|
||||
class="filter-chip">
|
||||
Suggeriti
|
||||
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per autore</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome autore..."
|
||||
[value]="searchAuthor()"
|
||||
(input)="onSearchAuthorInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchAuthor()" (click)="searchAuthor.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('author')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-button
|
||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleTopTen()"
|
||||
class="filter-chip">
|
||||
Top Ten
|
||||
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per testo</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Parole nel testo..."
|
||||
[value]="searchText()"
|
||||
(input)="onSearchTextInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchText()" (click)="searchText.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('text')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adv-input-group">
|
||||
<label class="adv-label">Ricerca per nr canto</label>
|
||||
<div class="adv-input-wrapper">
|
||||
<ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Numero canto..."
|
||||
[value]="searchNumber()"
|
||||
(input)="onSearchNumberInput($event)"
|
||||
class="adv-input" />
|
||||
<ion-icon name="close-circle" *ngIf="searchNumber()" (click)="searchNumber.set('')" class="adv-clear-icon"></ion-icon>
|
||||
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('number')" class="adv-mic-btn">
|
||||
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'danger' : 'secondary'"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Card (default closed) -->
|
||||
<div class="filter-card glass outfit-font"
|
||||
*ngIf="isPlaylistCardOpen()"
|
||||
(touchstart)="onPanelTouchStart($event)"
|
||||
(touchend)="onPanelTouchEnd($event, 'playlistCard')">
|
||||
<div class="filter-card-row">
|
||||
<span class="filter-row-label">Playlist</span>
|
||||
<div class="filter-row-items">
|
||||
<!-- Comunità filter button if enabled -->
|
||||
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
||||
<ion-button
|
||||
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleComunitaFilter()"
|
||||
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
|
||||
class="filter-chip"
|
||||
style="margin: 0;">
|
||||
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
|
||||
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Chips -->
|
||||
<div
|
||||
*ngFor="let item of playlistService.allPlaylists()"
|
||||
class="momento-chip glass"
|
||||
[class.active]="playlistService.activePlaylistId() === item.id"
|
||||
[class.comunita-chip]="item.isComunita"
|
||||
(click)="playlistService.activePlaylistId() === item.id ? clearSpecialList($event) : selectPlaylist(item)">
|
||||
<ion-icon *ngIf="item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
||||
{{ item.name }}
|
||||
<span *ngIf="playlistService.activePlaylistId() === item.id && isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 18px; height: 18px; vertical-align: middle;">
|
||||
<ion-icon name="refresh-outline" style="font-size: 0.85rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
|
||||
</span>
|
||||
<span class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
|
||||
Nessuna playlist creata
|
||||
</span>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-card-footer" *ngIf="hasPlaylistCardParams()">
|
||||
<div></div>
|
||||
<button type="button" class="clear-advanced-link outfit-font" (click)="clearPlaylistCard()">
|
||||
<ion-icon name="close-circle-outline"></ion-icon>
|
||||
<span>Reset liste</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Card (default closed) -->
|
||||
<div class="filter-card glass outfit-font"
|
||||
*ngIf="isFilterCardOpen()"
|
||||
(touchstart)="onPanelTouchStart($event)"
|
||||
(touchend)="onPanelTouchEnd($event, 'filterCard')">
|
||||
|
||||
<!-- Row 2: Tipologia -->
|
||||
<div class="filter-card-row">
|
||||
<span class="filter-row-label">Tipologia</span>
|
||||
<div class="filter-row-items">
|
||||
<div
|
||||
class="momento-chip glass"
|
||||
[class.active]="showValidati()"
|
||||
(click)="toggleValidati()">
|
||||
Validati
|
||||
<span class="close-icon-wrapper" *ngIf="showValidati()" (click)="clearListaCompleta($event)">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="momento-chip glass"
|
||||
[class.active]="showNonValidati()"
|
||||
(click)="toggleNonValidati()"
|
||||
style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<span>Non Validati</span>
|
||||
<ion-icon
|
||||
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
|
||||
name="add-outline"
|
||||
(click)="$event.stopPropagation()"
|
||||
routerLink="/propose-canto"
|
||||
class="non-validati-add-icon">
|
||||
</ion-icon>
|
||||
<span class="close-icon-wrapper" *ngIf="showNonValidati()" (click)="clearListaCompleta($event)">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Liturgia -->
|
||||
<div class="filter-card-row">
|
||||
<span class="filter-row-label">Liturgia</span>
|
||||
<div class="filter-row-items">
|
||||
<div
|
||||
*ngFor="let item of cantiService.indiceLiturgico()"
|
||||
class="momento-chip glass"
|
||||
[class.active]="selectedLiturgico() === item.id"
|
||||
(click)="toggleIndex(item.id, 'liturgico')">
|
||||
{{ item.tag_name }}
|
||||
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() === item.id" (click)="clearLiturgico($event)">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Periodo -->
|
||||
<div class="filter-card-row">
|
||||
<span class="filter-row-label">Periodo</span>
|
||||
<div class="filter-row-items">
|
||||
<div
|
||||
*ngFor="let item of cantiService.indiceTematico()"
|
||||
class="momento-chip glass"
|
||||
[class.active]="selectedTematico() === item.id"
|
||||
(click)="toggleIndex(item.id, 'tematico')">
|
||||
{{ item.tag_name }}
|
||||
<span class="close-icon-wrapper" *ngIf="selectedTematico() === item.id" (click)="clearTematico($event)">
|
||||
<ion-icon name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 5 (Footer): Suggeriti, Top Ten & Reset filtri -->
|
||||
<div class="filter-card-footer">
|
||||
<div class="footer-actions">
|
||||
<ion-button
|
||||
[fill]="showSuggeriti() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleSuggeriti()"
|
||||
class="filter-chip">
|
||||
Suggeriti
|
||||
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleTopTen()"
|
||||
class="filter-chip">
|
||||
Top Ten
|
||||
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showOnlyMine() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleOnlyMine()"
|
||||
class="filter-chip">
|
||||
Miei
|
||||
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
|
||||
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
|
||||
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
|
||||
<div class="playlist-actions-bar">
|
||||
<!-- Duration Badge on the left -->
|
||||
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>{{ totalPlaylistDuration() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Name in middle -->
|
||||
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
|
||||
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Active Playlist Actions -->
|
||||
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
|
||||
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
|
||||
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
|
||||
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
|
||||
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
|
||||
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Category Scroll (Dropdown style) -->
|
||||
<ion-toolbar class="bg-gradient momentos-toolbar" *ngIf="shouldShowSubSectionToolbar()">
|
||||
<div class="momento-scroll" *ngIf="activeFilterType() !== 'playlist' || playlistService.allPlaylists().length > 0 || playlistService.selectionMode()">
|
||||
<!-- Selection Mode Actions -->
|
||||
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
|
||||
<span class="selection-count">{{ reorderList().length }}</span>
|
||||
@@ -166,68 +380,6 @@
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Active Playlist Actions -->
|
||||
<div class="selection-pill glass" *ngIf="activeFilterType() === 'playlist' && playlistService.activePlaylistId() && !playlistService.selectionMode()">
|
||||
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
|
||||
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
|
||||
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activePlaylistId() && !isComunitaPlaylist()">
|
||||
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Filter chips -->
|
||||
<ng-container *ngIf="activeFilterType() === 'lista_completa'">
|
||||
<div
|
||||
class="momento-chip glass"
|
||||
[class.active]="showValidati()"
|
||||
(click)="toggleValidati()">
|
||||
Validati
|
||||
</div>
|
||||
<div
|
||||
class="momento-chip glass"
|
||||
[class.active]="showNonValidati()"
|
||||
(click)="toggleNonValidati()"
|
||||
style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<span>Non Validati</span>
|
||||
<ion-icon
|
||||
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
|
||||
name="add-outline"
|
||||
(click)="$event.stopPropagation()"
|
||||
routerLink="/propose-canto"
|
||||
class="non-validati-add-icon">
|
||||
</ion-icon>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="activeFilterType() !== 'lista_completa'">
|
||||
<div
|
||||
*ngFor="let item of (activeFilterType() === 'playlist' ? playlistService.allPlaylists() : (activeFilterType() === 'liturgico' ? cantiService.indiceLiturgico() : (activeFilterType() === 'tematico' ? cantiService.indiceTematico() : [])))"
|
||||
class="momento-chip glass"
|
||||
[class.active]="activeFilterType() === 'playlist' ? playlistService.activePlaylistId() === item.id : isIndexSelected(item.id)"
|
||||
[class.comunita-chip]="activeFilterType() === 'playlist' && item.isComunita"
|
||||
(click)="activeFilterType() === 'playlist' ? selectPlaylist(item) : toggleIndex(item.id, activeFilterType()!)">
|
||||
<ion-icon *ngIf="activeFilterType() === 'playlist' && item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
||||
{{ activeFilterType() === 'playlist' ? item.name : item.tag_name }}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Instructions when playlist is empty -->
|
||||
<div class="empty-playlist-container" *ngIf="activeFilterType() === 'playlist' && playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()">
|
||||
<p class="empty-playlist-instruction outfit-font">
|
||||
Per creare una playlist, seleziona il nr del canto che vuoi inserire nella playlist, riordinali e salvala con nome
|
||||
</p>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -244,6 +396,7 @@
|
||||
<div class="loading-wrapper">
|
||||
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
||||
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
|
||||
<ion-progress-bar [value]="cantiService.progress() / 100" color="secondary" style="width: 200px; border-radius: 10px; height: 8px; margin-top: 8px;"></ion-progress-bar>
|
||||
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,7 +626,6 @@
|
||||
<div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)">
|
||||
<div class="black-screen-content" (click)="$event.stopPropagation()">
|
||||
<div (click)="isBlackOverlayDismissed.set(true)" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
|
||||
<ion-icon name="car-outline" class="car-mode-icon"></ion-icon>
|
||||
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
|
||||
</div>
|
||||
|
||||
|
||||
+410
-62
@@ -48,102 +48,447 @@
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.voice-search-btn {
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
.adv-search-btn, .voice-search-btn {
|
||||
--padding-start: 6px;
|
||||
--padding-end: 6px;
|
||||
margin: 0;
|
||||
height: 44px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-search-btn.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions-row {
|
||||
.advanced-search-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
gap: 12px;
|
||||
padding: 8px 4px 8px 0;
|
||||
|
||||
// Hide scrollbar but keep functionality
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.toggle-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
.song-count-card {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 12px;
|
||||
padding: 0 8px;
|
||||
border-radius: 12px;
|
||||
height: 32px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
height: 30px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.playlist-total-duration {
|
||||
font-weight: 400;
|
||||
opacity: 0.75;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.01em;
|
||||
.advanced-search-link, .clear-advanced-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--ion-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
padding: 4px 5px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover, &:active {
|
||||
opacity: 1;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.12);
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inline-clear-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-danger, #ff4961);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
.clear-advanced-link {
|
||||
color: var(--ion-color-danger, #ff4961);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover, &:active {
|
||||
background: rgba(255, 73, 97, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-search-panel {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
backdrop-filter: blur(12px);
|
||||
animation: advSearchFadeIn 0.25s ease-out;
|
||||
|
||||
.adv-search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.adv-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
ion-button.filter-chip {
|
||||
--border-radius: 20px;
|
||||
--border-width: 1px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
min-height: 32px;
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
.adv-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.close-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 6px;
|
||||
padding: 4px;
|
||||
margin-right: -8px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
.adv-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 0 10px;
|
||||
height: 38px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--ion-color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.adv-input-icon {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.adv-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.9);
|
||||
.adv-clear-icon {
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-mic-btn {
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
margin: 0 0 0 2px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes advSearchFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.advanced-search-link {
|
||||
color: var(--ion-color-secondary) !important;
|
||||
}
|
||||
|
||||
.clear-advanced-link {
|
||||
color: var(--ion-color-danger, #d32f2f) !important;
|
||||
}
|
||||
|
||||
.advanced-search-panel {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
|
||||
.adv-input-group {
|
||||
.adv-label {
|
||||
color: var(--ion-color-secondary, #d96b00) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.adv-input-wrapper {
|
||||
background: #f4f5f8 !important;
|
||||
border: 1px solid #c0c4cc !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--ion-color-secondary, #d96b00) !important;
|
||||
box-shadow: 0 0 0 2px rgba(217, 107, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.adv-input-icon {
|
||||
color: var(--ion-color-secondary, #d96b00) !important;
|
||||
}
|
||||
|
||||
.adv-input {
|
||||
color: #000000 !important;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #666666 !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.adv-clear-icon {
|
||||
color: #666666 !important;
|
||||
|
||||
&:hover {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(12px);
|
||||
margin-top: 4px;
|
||||
|
||||
.filter-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
|
||||
.filter-row-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-color-secondary);
|
||||
width: 68px;
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-row-items {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-playlist-text {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
.song-count-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
height: 32px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
ion-button.filter-chip {
|
||||
--border-radius: 20px;
|
||||
--border-width: 1px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
min-height: 32px;
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
|
||||
.close-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 6px;
|
||||
padding: 4px;
|
||||
margin-right: -8px;
|
||||
cursor: pointer;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
ion-toolbar.playlist-toolbar {
|
||||
--padding-top: 0px;
|
||||
--padding-bottom: 8px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--min-height: auto;
|
||||
}
|
||||
|
||||
.playlist-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
.playlist-duration-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||
padding: 0 10px;
|
||||
border-radius: 12px;
|
||||
height: 30px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
margin: 0 8px;
|
||||
flex-shrink: 1;
|
||||
|
||||
.playlist-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-pill {
|
||||
margin: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.transparent-list {
|
||||
background: transparent !important;
|
||||
padding-bottom: 120px; // Spazio per il player fisso
|
||||
@@ -237,16 +582,17 @@ ion-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 0 16px 24px; // Spacing adjusted for search bar breathing room
|
||||
gap: 8px;
|
||||
padding: 10px 0 10px 8px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-text-group {
|
||||
@@ -257,17 +603,19 @@ ion-title {
|
||||
line-height: 1;
|
||||
|
||||
.app-name {
|
||||
font-size: 1.4rem;
|
||||
font-size: clamp(1.1rem, 3.8vw, 1.25rem);
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+393
-32
@@ -51,6 +51,18 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
|
||||
public searchQuery = signal<string>('');
|
||||
public isAdvancedSearchOpen = signal<boolean>(false);
|
||||
public isFilterCardOpen = signal<boolean>(false);
|
||||
public isPlaylistCardOpen = signal<boolean>(false);
|
||||
public searchTitle = signal<string>('');
|
||||
public searchAuthor = signal<string>('');
|
||||
public searchText = signal<string>('');
|
||||
public searchNumber = signal<string>('');
|
||||
public activeVoiceField = signal<'global' | 'title' | 'author' | 'text' | 'number'>('global');
|
||||
|
||||
public hasAdvancedSearchParams = computed(() => {
|
||||
return !!(this.searchTitle().trim() || this.searchAuthor().trim() || this.searchText().trim() || this.searchNumber().trim());
|
||||
});
|
||||
public selectedLiturgico = signal<number | null>(null);
|
||||
public selectedTematico = signal<number | null>(null);
|
||||
public showOnlyMine = signal<boolean>(false);
|
||||
@@ -58,6 +70,25 @@ export class HomePage implements OnDestroy {
|
||||
public showSuggeriti = signal<boolean>(false);
|
||||
public showValidati = signal<boolean>(false);
|
||||
public showNonValidati = signal<boolean>(false);
|
||||
|
||||
public hasPlaylistCardParams = computed(() => {
|
||||
return !!(
|
||||
this.playlistService.activePlaylistId() !== null ||
|
||||
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
|
||||
);
|
||||
});
|
||||
|
||||
public hasFilterCardParams = computed(() => {
|
||||
return !!(
|
||||
this.selectedLiturgico() !== null ||
|
||||
this.selectedTematico() !== null ||
|
||||
this.showOnlyMine() ||
|
||||
this.showTopTen() ||
|
||||
this.showSuggeriti() ||
|
||||
this.showValidati() ||
|
||||
this.showNonValidati()
|
||||
);
|
||||
});
|
||||
public isMassCardExpanded = signal<boolean>(false);
|
||||
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | 'lista_completa' | null>(null);
|
||||
public loadedThumbs = new Set<string>();
|
||||
@@ -205,6 +236,24 @@ export class HomePage implements OnDestroy {
|
||||
} catch (e) {
|
||||
console.warn('[PWA-Update] activateUpdate failed:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const registration of registrations) {
|
||||
await registration.unregister();
|
||||
}
|
||||
}
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PWA-Update] Cleanup failed:', e);
|
||||
}
|
||||
|
||||
overlay.finish();
|
||||
setTimeout(() => {
|
||||
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
|
||||
@@ -239,17 +288,12 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
private async checkUpdateStatus() {
|
||||
try {
|
||||
let swFoundUpdate = false;
|
||||
if (this.swUpdate.isEnabled) {
|
||||
const swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
|
||||
if (swFoundUpdate) {
|
||||
this.hasUpdateAvailable.set(true);
|
||||
return;
|
||||
}
|
||||
swFoundUpdate = await this.swUpdate.checkForUpdate().catch(() => false);
|
||||
}
|
||||
const mismatch = await this.checkVersionJson();
|
||||
if (mismatch) {
|
||||
this.hasUpdateAvailable.set(true);
|
||||
}
|
||||
this.hasUpdateAvailable.set(swFoundUpdate || mismatch);
|
||||
} catch (err) {
|
||||
console.warn('Failed to check for updates on startup:', err);
|
||||
}
|
||||
@@ -415,36 +459,85 @@ export class HomePage implements OnDestroy {
|
||||
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
||||
});
|
||||
} else {
|
||||
const tokens = query.split(/\s+/).filter(t => t.length > 0);
|
||||
|
||||
list = list.filter(c => {
|
||||
const titleMatch = this.normalize(c.titolo).includes(query);
|
||||
const authorMatch = this.normalize(c.autore).includes(query);
|
||||
const normTitle = this.normalize(c.titolo);
|
||||
const normAuthor = this.normalize(c.autore);
|
||||
const commNum = this.getCommunitySongNumber(c);
|
||||
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
|
||||
const songNum = commNum || c.id_canti.toString();
|
||||
|
||||
const lyricsPlain = (c.testo || '')
|
||||
.replace(/{.*?}/g, '')
|
||||
.replace(/\n/g, ' ');
|
||||
const lyricsMatch = this.normalize(lyricsPlain).includes(query);
|
||||
const normLyrics = this.normalize(lyricsPlain);
|
||||
|
||||
return titleMatch || authorMatch || lyricsMatch || numberMatch;
|
||||
// Full string matches in title, author, lyrics, or song number
|
||||
const titleMatch = normTitle.includes(query);
|
||||
const authorMatch = normAuthor.includes(query);
|
||||
const lyricsMatch = normLyrics.includes(query);
|
||||
const numberMatch = songNum.includes(query);
|
||||
|
||||
if (titleMatch || authorMatch || lyricsMatch || numberMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Multi-word token match across fields (e.g. title word + author word)
|
||||
if (tokens.length > 1) {
|
||||
return tokens.every(token =>
|
||||
normTitle.includes(token) ||
|
||||
normAuthor.includes(token) ||
|
||||
normLyrics.includes(token) ||
|
||||
songNum.includes(token)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced search filters (titolo, autore, testo, nr canto)
|
||||
const advTitle = this.normalize(this.searchTitle()).trim();
|
||||
const advAuthor = this.normalize(this.searchAuthor()).trim();
|
||||
const advText = this.normalize(this.searchText()).trim();
|
||||
const advNumber = this.normalize(this.searchNumber()).trim();
|
||||
|
||||
if (advTitle) {
|
||||
list = list.filter(c => this.normalize(c.titolo).includes(advTitle));
|
||||
}
|
||||
|
||||
if (advAuthor) {
|
||||
list = list.filter(c => this.normalize(c.autore || '').includes(advAuthor));
|
||||
}
|
||||
|
||||
if (advText) {
|
||||
list = list.filter(c => {
|
||||
const lyricsPlain = (c.testo || '')
|
||||
.replace(/{.*?}/g, '')
|
||||
.replace(/\n/g, ' ');
|
||||
return this.normalize(lyricsPlain).includes(advText);
|
||||
});
|
||||
}
|
||||
|
||||
if (advNumber) {
|
||||
list = list.filter(c => {
|
||||
const commNum = this.getCommunitySongNumber(c);
|
||||
const songNum = (commNum ? commNum.toString() : c.id_canti.toString()).toLowerCase().trim();
|
||||
return songNum === advNumber.toLowerCase().trim();
|
||||
});
|
||||
}
|
||||
|
||||
// G. Sorting/Ordering
|
||||
if (topTen) {
|
||||
const eseguiti = this.cantiService.cantiEseguiti();
|
||||
const eseguitiMap = new Map<number, number>();
|
||||
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
||||
|
||||
list = list.sort((a, b) => {
|
||||
const numA = eseguitiMap.get(a.id_canti) || 0;
|
||||
const numB = eseguitiMap.get(b.id_canti) || 0;
|
||||
list = [...list].sort((a, b) => {
|
||||
const numA = this.getEsecuzioniCount(a.id) || 0;
|
||||
const numB = this.getEsecuzioniCount(b.id) || 0;
|
||||
return numB - numA;
|
||||
});
|
||||
} else if (suggeriti) {
|
||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||
list = list.sort((a, b) => {
|
||||
list = [...list].sort((a, b) => {
|
||||
const pesoA = suggMap.get(a.id_canti) || 0;
|
||||
const pesoB = suggMap.get(b.id_canti) || 0;
|
||||
return pesoB - pesoA;
|
||||
@@ -464,8 +557,24 @@ export class HomePage implements OnDestroy {
|
||||
return this.filteredCanti().slice(0, this.limit());
|
||||
});
|
||||
|
||||
public hasAnyFilter = computed(() => {
|
||||
return (
|
||||
this.playlistService.activeListName() !== null ||
|
||||
this.selectedLiturgico() !== null ||
|
||||
this.selectedTematico() !== null ||
|
||||
this.showOnlyMine() ||
|
||||
this.showTopTen() ||
|
||||
this.showSuggeriti() ||
|
||||
this.showValidati() ||
|
||||
this.showNonValidati() ||
|
||||
!!this.searchQuery().trim() ||
|
||||
this.hasAdvancedSearchParams() ||
|
||||
(this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive())
|
||||
);
|
||||
});
|
||||
|
||||
public totalPlaylistDuration = computed(() => {
|
||||
if (this.activeFilterType() !== 'playlist' || this.playlistService.activeListName() === null) return '';
|
||||
if (this.playlistService.activePlaylistId() === null) return '';
|
||||
const songs = this.filteredCanti();
|
||||
if (songs.length === 0) return '';
|
||||
|
||||
@@ -523,7 +632,11 @@ export class HomePage implements OnDestroy {
|
||||
this.playlistService.checkForRemotePlaylistUpdates();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Sync homepage filtered canti IDs to playlistService.filteredListIds
|
||||
effect(() => {
|
||||
const ids = this.filteredCanti().map(c => c.id);
|
||||
this.playlistService.filteredListIds.set(ids);
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
|
||||
// Track initial community filter state to avoid clearing during the initial run of the effect
|
||||
@@ -555,11 +668,29 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
// Sync speech recognition results to search query
|
||||
// Sync speech recognition results to search query or advanced field
|
||||
effect(() => {
|
||||
const transcript = this.audioEngine.searchTranscript();
|
||||
if (transcript) {
|
||||
this.searchQuery.set(transcript);
|
||||
const target = this.activeVoiceField();
|
||||
switch (target) {
|
||||
case 'title':
|
||||
this.searchTitle.set(transcript);
|
||||
break;
|
||||
case 'author':
|
||||
this.searchAuthor.set(transcript);
|
||||
break;
|
||||
case 'text':
|
||||
this.searchText.set(transcript);
|
||||
break;
|
||||
case 'number':
|
||||
this.searchNumber.set(transcript);
|
||||
break;
|
||||
case 'global':
|
||||
default:
|
||||
this.searchQuery.set(transcript);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -943,6 +1074,7 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
// Select it immediately
|
||||
this.selectPlaylist(selectedPl);
|
||||
this.playlistService.lastPlaylist.set(selectedPl);
|
||||
this.activeFilterType.set('playlist');
|
||||
|
||||
await loading.dismiss();
|
||||
@@ -1097,6 +1229,95 @@ export class HomePage implements OnDestroy {
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
toggleAdvancedSearch() {
|
||||
const willOpen = !this.isAdvancedSearchOpen();
|
||||
this.isAdvancedSearchOpen.set(willOpen);
|
||||
if (willOpen) {
|
||||
this.isFilterCardOpen.set(false);
|
||||
this.isPlaylistCardOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFilterCard() {
|
||||
const willOpen = !this.isFilterCardOpen();
|
||||
this.isFilterCardOpen.set(willOpen);
|
||||
if (willOpen) {
|
||||
this.isAdvancedSearchOpen.set(false);
|
||||
this.isPlaylistCardOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
togglePlaylistCard() {
|
||||
const willOpen = !this.isPlaylistCardOpen();
|
||||
this.isPlaylistCardOpen.set(willOpen);
|
||||
if (willOpen) {
|
||||
this.isAdvancedSearchOpen.set(false);
|
||||
this.isFilterCardOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
clearAdvancedSearch(event?: Event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.searchTitle.set('');
|
||||
this.searchAuthor.set('');
|
||||
this.searchText.set('');
|
||||
this.searchNumber.set('');
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
clearFilterCard(event?: Event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.selectedLiturgico.set(null);
|
||||
this.selectedTematico.set(null);
|
||||
this.showOnlyMine.set(false);
|
||||
this.showTopTen.set(false);
|
||||
this.showSuggeriti.set(false);
|
||||
this.showValidati.set(false);
|
||||
this.showNonValidati.set(false);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
clearPlaylistCard(event?: Event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.playlistService.activeListIds.set([]);
|
||||
this.playlistService.activeListName.set(null);
|
||||
this.playlistService.activePlaylistId.set(null);
|
||||
if (this.settingsService.comunitaEnabled() && this.comunitaService.isFilterActive()) {
|
||||
this.toggleComunitaFilter();
|
||||
}
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
onSearchTitleInput(event: any) {
|
||||
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||
this.searchTitle.set(val);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
onSearchAuthorInput(event: any) {
|
||||
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||
this.searchAuthor.set(val);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
onSearchTextInput(event: any) {
|
||||
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||
this.searchText.set(val);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
onSearchNumberInput(event: any) {
|
||||
const val = event.target?.value ?? event.detail?.value ?? '';
|
||||
this.searchNumber.set(val);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
async deleteMyCanto(id: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
const alert = await this.alertCtrl.create({
|
||||
@@ -1256,7 +1477,8 @@ export class HomePage implements OnDestroy {
|
||||
this.showValidati() ||
|
||||
this.showNonValidati() ||
|
||||
this.playlistService.activeListName() !== null ||
|
||||
this.searchQuery() !== '';
|
||||
this.searchQuery() !== '' ||
|
||||
this.hasAdvancedSearchParams();
|
||||
}
|
||||
|
||||
shouldShowSubSectionToolbar(): boolean {
|
||||
@@ -1282,6 +1504,7 @@ export class HomePage implements OnDestroy {
|
||||
this.playlistService.activeListName.set(null);
|
||||
this.playlistService.activePlaylistId.set(null);
|
||||
this.searchQuery.set('');
|
||||
this.clearAdvancedSearch();
|
||||
this.activeFilterType.set(null);
|
||||
this.limit.set(10);
|
||||
}
|
||||
@@ -1315,6 +1538,8 @@ export class HomePage implements OnDestroy {
|
||||
if (pl.isRemote || pl.id?.startsWith('remote_')) {
|
||||
this.playlistService.checkForRemotePlaylistUpdates();
|
||||
}
|
||||
this.isAdvancedSearchOpen.set(false);
|
||||
this.isFilterCardOpen.set(false);
|
||||
// Mantieni il menu aperto poiché la playlist è ora selezionata ed attiva
|
||||
this.limit.set(50);
|
||||
}
|
||||
@@ -1720,10 +1945,16 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
toggleVoiceSearch() {
|
||||
toggleVoiceSearch(target: 'global' | 'title' | 'author' | 'text' | 'number' = 'global') {
|
||||
if (this.audioEngine.isSearching()) {
|
||||
const currentTarget = this.activeVoiceField();
|
||||
this.audioEngine.stopSearchRecognition();
|
||||
if (currentTarget !== target) {
|
||||
this.activeVoiceField.set(target);
|
||||
this.audioEngine.startSearchRecognition();
|
||||
}
|
||||
} else {
|
||||
this.activeVoiceField.set(target);
|
||||
this.audioEngine.startSearchRecognition();
|
||||
}
|
||||
}
|
||||
@@ -1794,12 +2025,24 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
isComunitaPlaylist(): boolean {
|
||||
const id = this.playlistService.activePlaylistId();
|
||||
return !!id && id.startsWith('comunita_');
|
||||
if (id && id.startsWith('comunita_')) return true;
|
||||
const name = this.playlistService.activeListName();
|
||||
if (name) {
|
||||
const pl = this.playlistService.allPlaylists().find(p => p.name === name);
|
||||
return !!pl?.isComunita;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isRemotePlaylist(): boolean {
|
||||
const id = this.playlistService.activePlaylistId();
|
||||
return !!id && id.startsWith('remote_');
|
||||
if (id && id.startsWith('remote_')) return true;
|
||||
const name = this.playlistService.activeListName();
|
||||
if (name) {
|
||||
const pl = this.playlistService.allPlaylists().find(p => p.name === name);
|
||||
return !!pl?.isRemote;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async refreshActiveRemotePlaylist() {
|
||||
@@ -1829,8 +2072,10 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
isActivePlaylistSaved(): boolean {
|
||||
const id = this.playlistService.activePlaylistId();
|
||||
if (!id) return false;
|
||||
return this.playlistService.playlists().some(p => p.id === id);
|
||||
if (id) return this.playlistService.playlists().some(p => p.id === id);
|
||||
const name = this.playlistService.activeListName();
|
||||
if (name) return this.playlistService.playlists().some(p => p.name === name);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearSpecialList(event?: Event) {
|
||||
@@ -1860,6 +2105,59 @@ export class HomePage implements OnDestroy {
|
||||
this.playlistService.sharePlaylistQR(ids, name, songSettings);
|
||||
}
|
||||
|
||||
async printActivePlaylist() {
|
||||
const ids = this.playlistService.activeListIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const allCanti = this.cantiService.canti();
|
||||
const myCantiList = this.myCantiService.myCanti();
|
||||
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
|
||||
const communityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive();
|
||||
|
||||
const songs = ids.map(id => {
|
||||
let found: any = null;
|
||||
if (communityActive) {
|
||||
found = comunitaCantiPers.find(c => c.id === id);
|
||||
}
|
||||
if (!found) {
|
||||
found = allCanti.find(c => c.id === id);
|
||||
}
|
||||
if (!found) {
|
||||
found = myCantiList.find(c => c.id === id);
|
||||
}
|
||||
if (!found) {
|
||||
found = comunitaCantiPers.find(c => c.id === id);
|
||||
}
|
||||
return found;
|
||||
}).filter(c => !!c);
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Esporta in PDF',
|
||||
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Solo Testo',
|
||||
handler: () => {
|
||||
const name = this.playlistService.activeListName() || 'Playlist';
|
||||
this.playlistService.printPlaylist(name, songs, false);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Testo e Accordi',
|
||||
handler: () => {
|
||||
const name = this.playlistService.activeListName() || 'Playlist';
|
||||
this.playlistService.printPlaylist(name, songs, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
async cloneActivePlaylist() {
|
||||
const id = this.playlistService.activePlaylistId();
|
||||
const currentName = this.playlistService.activeListName() || 'Playlist';
|
||||
@@ -2018,7 +2316,40 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
getSongTonality(canto: any): string | null {
|
||||
if (!canto) return null;
|
||||
return this.lyricsParser.deduceTonality(canto.accordi || canto.testo || '');
|
||||
const baseKey = this.lyricsParser.deduceTonality(canto.accordi || canto.testo || '');
|
||||
if (!baseKey) return null;
|
||||
|
||||
let semitones = 0;
|
||||
const activePlaylistId = this.playlistService.activePlaylistId();
|
||||
if (activePlaylistId) {
|
||||
let playlistSongSetting: any = null;
|
||||
if (activePlaylistId.startsWith('remote_')) {
|
||||
const pl = this.playlistService.remotePlaylist();
|
||||
if (pl && pl.songSettings && pl.songSettings[canto.id]) {
|
||||
playlistSongSetting = pl.songSettings[canto.id];
|
||||
}
|
||||
} else {
|
||||
const pl = this.playlistService.playlists().find(p => p.id === activePlaylistId);
|
||||
if (pl && pl.songSettings && pl.songSettings[canto.id]) {
|
||||
playlistSongSetting = pl.songSettings[canto.id];
|
||||
}
|
||||
}
|
||||
if (playlistSongSetting && playlistSongSetting.tonalita !== undefined) {
|
||||
semitones = playlistSongSetting.tonalita;
|
||||
}
|
||||
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||
const settings = this.comunitaService.comunitaCantiSettings();
|
||||
const songSetting = settings.find(s => s.id_canti === canto.id_canti || s.id_canti === Number(canto.id));
|
||||
if (songSetting && songSetting.tonalita !== undefined) {
|
||||
semitones = songSetting.tonalita;
|
||||
}
|
||||
}
|
||||
|
||||
if (semitones !== 0) {
|
||||
return this.lyricsParser.transposeChord(baseKey, semitones);
|
||||
}
|
||||
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
toggleComunitaFilter() {
|
||||
@@ -2062,6 +2393,36 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private panelStartX: number = 0;
|
||||
private panelStartY: number = 0;
|
||||
|
||||
onPanelTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length === 1) {
|
||||
this.panelStartX = event.touches[0].clientX;
|
||||
this.panelStartY = event.touches[0].clientY;
|
||||
}
|
||||
}
|
||||
|
||||
onPanelTouchEnd(event: TouchEvent, panelType: 'advancedSearch' | 'playlistCard' | 'filterCard') {
|
||||
if (event.changedTouches.length === 1) {
|
||||
const endX = event.changedTouches[0].clientX;
|
||||
const endY = event.changedTouches[0].clientY;
|
||||
const diffX = endX - this.panelStartX;
|
||||
const diffY = endY - this.panelStartY;
|
||||
|
||||
// Swipe UP: vertical movement upwards (diffY negative, e.g. < -40) and larger than horizontal diff
|
||||
if (diffY < -40 && Math.abs(diffY) > Math.abs(diffX)) {
|
||||
if (panelType === 'advancedSearch') {
|
||||
this.isAdvancedSearchOpen.set(false);
|
||||
} else if (panelType === 'playlistCard') {
|
||||
this.isPlaylistCardOpen.set(false);
|
||||
} else if (panelType === 'filterCard') {
|
||||
this.isFilterCardOpen.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigateMassDate(direction: number) {
|
||||
const list = this.cantiLettureService.availableMasses();
|
||||
if (list.length === 0) return;
|
||||
|
||||
@@ -4,36 +4,58 @@
|
||||
|
||||
<!-- Row 1: Number + Title -->
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;">
|
||||
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
|
||||
[style.fontSize.rem]="0.85 * fontSize()"
|
||||
[style.width.px]="32 * fontSize()"
|
||||
[style.height.px]="32 * fontSize()"
|
||||
[style.lineHeight.px]="28 * fontSize()"
|
||||
[style.borderRadius.px]="8 * fontSize()">
|
||||
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
|
||||
</span>
|
||||
<span class="canto-title" style="font-weight: 700; font-size: 1.15rem; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
|
||||
<span class="canto-title"
|
||||
[style.fontSize.rem]="1.15 * fontSize()"
|
||||
style="font-weight: 700; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
|
||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||
<span>{{ canto()?.titolo || 'Player' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Author -->
|
||||
<div *ngIf="!isLandscapeActive()" style="display: block; width: 100%; padding-left: 40px; margin-top: 1px; margin-bottom: 2px;">
|
||||
<span class="song-author" style="font-size: 0.95rem; color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
|
||||
<div *ngIf="!isLandscapeActive()"
|
||||
style="display: block; width: 100%; margin-top: 1px; margin-bottom: 2px;"
|
||||
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||
<span class="song-author"
|
||||
[style.fontSize.rem]="0.95 * fontSize()"
|
||||
style="color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
|
||||
{{ canto()?.autore || 'Autore sconosciuto' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Badges only -->
|
||||
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal" style="padding-left: 40px; margin-top: 2px; width: 100%;">
|
||||
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
|
||||
style="margin-top: 2px; width: 100%;"
|
||||
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||
<div style="display: flex; align-items: center; gap: inherit;">
|
||||
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||
<span *ngIf="canto()?.durata" class="song-duration-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span *ngIf="canto()?.durata" class="song-duration-badge"
|
||||
[style.fontSize.rem]="0.75 * fontSize()"
|
||||
[style.height.px]="26 * fontSize()"
|
||||
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||
{{ canto()?.durata }}
|
||||
</span>
|
||||
<span *ngIf="canto()?.bpm" class="song-bpm-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="pulse-outline"></ion-icon>
|
||||
<span *ngIf="canto()?.bpm" class="song-bpm-badge"
|
||||
[style.fontSize.rem]="0.75 * fontSize()"
|
||||
[style.height.px]="26 * fontSize()"
|
||||
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="pulse-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||
{{ canto()?.bpm }} BPM
|
||||
</span>
|
||||
<span *ngIf="tonality()" class="song-tonality-badge" style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="musical-note-outline"></ion-icon>
|
||||
<span *ngIf="tonality()" class="song-tonality-badge"
|
||||
[style.fontSize.rem]="0.75 * fontSize()"
|
||||
[style.height.px]="26 * fontSize()"
|
||||
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||
<ion-icon name="musical-note-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||
{{ tonality() }}
|
||||
</span>
|
||||
</ng-container>
|
||||
@@ -234,6 +256,9 @@
|
||||
[color]="showChords() ? 'secondary' : 'medium'">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="printCanto()" title="Esporta PDF">
|
||||
<ion-icon slot="icon-only" name="print-outline" color="secondary"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
@@ -256,7 +281,8 @@
|
||||
</ion-button>
|
||||
<div class="transpose-indicator">
|
||||
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
||||
<span class="val" *ngIf="transposeAmount() !== 0">{{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }}</span>
|
||||
<span class="val" *ngIf="tonality()">{{ tonality() }}</span>
|
||||
<span class="val" *ngIf="transposeAmount() !== 0" style="opacity: 0.8; font-size: 0.75rem; margin-left: 2px;">({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})</span>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
||||
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||
@@ -293,7 +319,6 @@
|
||||
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()">
|
||||
<div class="black-screen-content" (click)="$event.stopPropagation()">
|
||||
<div (click)="deactivateBlackScreen()" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
|
||||
<ion-icon name="car-outline" class="car-mode-icon"></ion-icon>
|
||||
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
public faceDetector = inject(FaceDetectorService);
|
||||
private meta = inject(Meta);
|
||||
|
||||
public enableCameraNavigation = signal<boolean>(false);
|
||||
public enableCameraNavigation = computed(() => this.settingsService.cameraNavigationActive());
|
||||
|
||||
private songStartTime: number = 0;
|
||||
|
||||
@@ -520,6 +520,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
ngAfterViewInit() {
|
||||
this.checkLandscapeZoom();
|
||||
this.checkTitleWrap();
|
||||
if (this.settingsService.cameraNavigationActive()) {
|
||||
this.initCameraTracking();
|
||||
}
|
||||
const gestureX = this.gestureCtrl.create({
|
||||
el: this.el.nativeElement,
|
||||
direction: 'x',
|
||||
@@ -977,11 +980,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
nextSong() {
|
||||
let list = this.playlistService.activeListIds();
|
||||
if (list.length === 0) {
|
||||
list = this.cantiService.canti().map(c => c.id);
|
||||
private getSequenceList(): string[] {
|
||||
const currentId = this.canto()?.id;
|
||||
|
||||
// 1. Homepage filtered list (includes current sorting like Top Ten, Suggeriti, and any active playlist + additional filters)
|
||||
const filtered = this.playlistService.filteredListIds();
|
||||
if (filtered.length > 0) {
|
||||
if (!currentId || filtered.includes(currentId)) {
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Active playlist raw list if present and contains current song
|
||||
const active = this.playlistService.activeListIds();
|
||||
if (active.length > 0) {
|
||||
if (!currentId || active.includes(currentId)) {
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default fallback: all canti
|
||||
return this.cantiService.canti().map(c => c.id);
|
||||
}
|
||||
|
||||
nextSong() {
|
||||
const list = this.getSequenceList();
|
||||
|
||||
const currentId = this.canto()?.id;
|
||||
if (!currentId) return;
|
||||
@@ -999,10 +1022,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
prevSong() {
|
||||
let list = this.playlistService.activeListIds();
|
||||
if (list.length === 0) {
|
||||
list = this.cantiService.canti().map(c => c.id);
|
||||
}
|
||||
const list = this.getSequenceList();
|
||||
|
||||
const currentId = this.canto()?.id;
|
||||
if (!currentId) return;
|
||||
@@ -1193,6 +1213,35 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async printCanto() {
|
||||
const c = this.canto();
|
||||
if (!c) return;
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Esporta in PDF',
|
||||
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Solo Testo',
|
||||
handler: () => {
|
||||
this.playlistService.printPlaylist(c.titolo, [c], false, { [c.id || c.id_canti]: this.transposeAmount() });
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Testo e Accordi',
|
||||
handler: () => {
|
||||
this.playlistService.printPlaylist(c.titolo, [c], true, { [c.id || c.id_canti]: this.transposeAmount() });
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
private initPlayer(id: string) {
|
||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||
return;
|
||||
@@ -1299,7 +1348,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
toggleCameraNavigation() {
|
||||
if (this.enableCameraNavigation()) {
|
||||
if (this.settingsService.cameraNavigationActive()) {
|
||||
this.stopCameraNavigation();
|
||||
} else {
|
||||
this.startCameraNavigation();
|
||||
@@ -1307,8 +1356,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
startCameraNavigation() {
|
||||
this.enableCameraNavigation.set(true);
|
||||
|
||||
this.settingsService.cameraNavigationActive.set(true);
|
||||
this.initCameraTracking();
|
||||
}
|
||||
|
||||
initCameraTracking() {
|
||||
if (!this.settingsService.cameraNavigationActive()) return;
|
||||
|
||||
setTimeout(async () => {
|
||||
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
|
||||
if (videoEl) {
|
||||
@@ -1321,16 +1375,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.prev(true);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.enableCameraNavigation.set(false);
|
||||
alert('Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.');
|
||||
} catch (e: any) {
|
||||
this.settingsService.cameraNavigationActive.set(false);
|
||||
const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto';
|
||||
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
stopCameraNavigation() {
|
||||
this.enableCameraNavigation.set(false);
|
||||
this.settingsService.cameraNavigationActive.set(false);
|
||||
this.faceDetector.stop();
|
||||
}
|
||||
|
||||
@@ -1338,7 +1393,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.exitFullscreen();
|
||||
this.stopAutoscroll();
|
||||
this.logPreviousSongTime();
|
||||
this.stopCameraNavigation();
|
||||
if (this.settingsService.cameraNavigationActive()) {
|
||||
this.faceDetector.pause();
|
||||
} else {
|
||||
this.faceDetector.stop();
|
||||
}
|
||||
this.channel.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
</ion-buttons>
|
||||
<ion-title class="outfit-font">Gestione Playlist</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="exportPlaylistPdf()" [disabled]="localSongs.length === 0" title="Esporta PDF">
|
||||
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
|
||||
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
@@ -272,4 +272,42 @@ export class PlaylistPage {
|
||||
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
|
||||
return index !== -1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
async exportPlaylistPdf() {
|
||||
if (this.localSongs.length === 0) return;
|
||||
|
||||
const songSettings = this.getPlaylistSongSettings() || {};
|
||||
const transpositions: { [songId: string]: number } = {};
|
||||
for (const key of Object.keys(songSettings)) {
|
||||
if (songSettings[key] && songSettings[key].tonalita !== undefined) {
|
||||
transpositions[key] = songSettings[key].tonalita;
|
||||
}
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Esporta in PDF',
|
||||
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Solo Testo',
|
||||
handler: () => {
|
||||
const name = this.savedPlaylistName || 'Playlist';
|
||||
this.playlistService.printPlaylist(name, this.localSongs, false, transpositions);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Testo e Accordi',
|
||||
handler: () => {
|
||||
const name = this.savedPlaylistName || 'Playlist';
|
||||
this.playlistService.printPlaylist(name, this.localSongs, true, transpositions);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Annulla',
|
||||
role: 'cancel'
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +85,14 @@
|
||||
<!-- TOOLBARS -->
|
||||
<div class="toolbar-section">
|
||||
<div class="horizontal-toolbar">
|
||||
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
|
||||
{{ tag.label }}
|
||||
</ion-button>
|
||||
<ng-container *ngFor="let tag of commonTags">
|
||||
<ion-button size="small" fill="outline" (click)="insertText(tag.start)">
|
||||
{{ tag.label }}
|
||||
</ion-button>
|
||||
<ion-button *ngIf="tag.end" size="small" fill="outline" color="medium" (click)="insertText(tag.end)">
|
||||
Fine {{ tag.label }}
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,6 +158,9 @@
|
||||
<ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF">
|
||||
<ion-icon name="document-attach-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="toggleRawPasteMode()" [color]="isRawPasteModeActive ? 'warning' : 'medium'" [title]="isRawPasteModeActive ? 'Incolla Raw: Attivo (il testo incollato non verrà filtrato)' : 'Attiva Incolla Raw (incolla senza filtri)'" style="font-size: 11px; font-weight: 800; font-family: 'Outfit', sans-serif;">
|
||||
RAW
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-textarea-container">
|
||||
|
||||
@@ -38,6 +38,7 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
|
||||
showChordsPreview: boolean = true;
|
||||
activeTab: string = 'editor';
|
||||
transposeAmount: number = 0;
|
||||
isRawPasteModeActive: boolean = false;
|
||||
|
||||
get highlightedHtml(): string {
|
||||
if (!this.content) return '';
|
||||
@@ -223,31 +224,31 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
|
||||
groupedChords = [
|
||||
{
|
||||
root: 'DO',
|
||||
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7']
|
||||
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DO4']
|
||||
},
|
||||
{
|
||||
root: 'RE',
|
||||
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7']
|
||||
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'RE4']
|
||||
},
|
||||
{
|
||||
root: 'MI',
|
||||
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim']
|
||||
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MI4']
|
||||
},
|
||||
{
|
||||
root: 'FA',
|
||||
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7']
|
||||
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FA-7', 'FA4']
|
||||
},
|
||||
{
|
||||
root: 'SOL',
|
||||
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7']
|
||||
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOL-7', 'SOL4']
|
||||
},
|
||||
{
|
||||
root: 'LA',
|
||||
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LAmaj7', 'LA4', 'LAdim', 'LAm7']
|
||||
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LA4']
|
||||
},
|
||||
{
|
||||
root: 'SI',
|
||||
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim']
|
||||
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SI4']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -333,6 +334,19 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
|
||||
this.insertText(`[${chord}]`);
|
||||
}
|
||||
|
||||
toggleRawPasteMode() {
|
||||
this.isRawPasteModeActive = !this.isRawPasteModeActive;
|
||||
|
||||
// Show a toast indicating whether the Raw Paste mode has been activated or deactivated
|
||||
this.toastController.create({
|
||||
message: this.isRawPasteModeActive
|
||||
? 'Modalità Incolla Raw ATTIVA: incolla liberamente senza filtri'
|
||||
: 'Modalità Incolla Raw DISATTIVA: i filtri automatici sono attivi',
|
||||
duration: 2500,
|
||||
color: this.isRawPasteModeActive ? 'warning' : 'primary'
|
||||
}).then(toast => toast.present());
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.undoStack.length > 0) {
|
||||
const previous = this.undoStack.pop();
|
||||
@@ -593,6 +607,10 @@ export class ProposeCantoPage implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async onPaste(event: ClipboardEvent) {
|
||||
if (this.isRawPasteModeActive) {
|
||||
// In raw paste mode, we let the browser handle paste natively with no processing
|
||||
return;
|
||||
}
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, signal, inject, effect } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface Suggestion {
|
||||
id_canto?: number;
|
||||
@@ -127,7 +128,18 @@ export class CantiLettureService {
|
||||
|
||||
const savedDate = localStorage.getItem('selected-mass-date');
|
||||
if (savedDate) {
|
||||
this.selectedMassDate.set(savedDate);
|
||||
const masses = this.availableMasses();
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
// Only restore savedDate if it exists in available masses and is not outdated
|
||||
const isValidAndNotPast = masses.some(m => m.date === savedDate && m.date >= todayStr);
|
||||
if (isValidAndNotPast) {
|
||||
this.selectedMassDate.set(savedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
@@ -137,24 +149,45 @@ export class CantiLettureService {
|
||||
async fetchData() {
|
||||
try {
|
||||
let fetchedData: CantiLettureData | null = null;
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
|
||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||
const primaryUrl = isProduction
|
||||
? `${window.location.origin}${this.SECURE_JSON_URL}`
|
||||
: 'https://www.canticristiani.it/api/cantiletture.json';
|
||||
const fallbackUrl = isProduction
|
||||
? 'https://www.canticristiani.it/api/cantiletture.json'
|
||||
: this.JSON_URL;
|
||||
|
||||
// 1. Try static JSON endpoint first
|
||||
try {
|
||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
if (res && res.masses && res.week_end && res.week_end >= todayStr) {
|
||||
fetchedData = res;
|
||||
} else {
|
||||
console.warn('Primary JSON data is missing or out of date:', res?.week_end);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err);
|
||||
console.warn('Primary fetch failed:', err);
|
||||
}
|
||||
|
||||
// 2. Fallback to direct API endpoint using Basic Auth credentials
|
||||
if (!fetchedData) {
|
||||
try {
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`));
|
||||
} catch (fallbackErr) {
|
||||
console.error('Fallback fetch also failed:', fallbackErr);
|
||||
console.log('Fetching mass data from direct API:', this.JSON_URL);
|
||||
const authUser = environment.apiAuthUser || 'canti';
|
||||
const authPass = environment.apiAuthPass || 'antani2026';
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': 'Basic ' + btoa(`${authUser}:${authPass}`)
|
||||
});
|
||||
const res = await firstValueFrom(this.http.get<CantiLettureData>(`${this.JSON_URL}?t=${Date.now()}`, { headers }));
|
||||
if (res && res.masses) {
|
||||
fetchedData = res;
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.error('Direct API fetch failed:', authErr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,24 +227,29 @@ export class CantiLettureService {
|
||||
|
||||
this.availableMasses.set(massesList);
|
||||
|
||||
// Always select today's mass if available on load, else find closest future date
|
||||
if (massesList.length > 0) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
const match = massesList.find(m => m.date === todayStr);
|
||||
if (match) {
|
||||
this.selectedMassDate.set(match.date);
|
||||
} else {
|
||||
// If today is not present, find the first date that is in the future
|
||||
const futureMatch = massesList.find(m => m.date >= todayStr);
|
||||
if (futureMatch) {
|
||||
this.selectedMassDate.set(futureMatch.date);
|
||||
|
||||
const currentSelected = this.selectedMassDate();
|
||||
const isCurrentValid = currentSelected && massesList.some(m => m.date === currentSelected && m.date >= todayStr);
|
||||
|
||||
if (!isCurrentValid) {
|
||||
const match = massesList.find(m => m.date === todayStr);
|
||||
if (match) {
|
||||
this.selectedMassDate.set(match.date);
|
||||
} else {
|
||||
// Otherwise fall back to the last available mass date (closest to today)
|
||||
this.selectedMassDate.set(massesList[massesList.length - 1].date);
|
||||
// If today is not present, find the first date that is in the future
|
||||
const futureMatch = massesList.find(m => m.date >= todayStr);
|
||||
if (futureMatch) {
|
||||
this.selectedMassDate.set(futureMatch.date);
|
||||
} else {
|
||||
// Otherwise fall back to the last available mass date (closest to today)
|
||||
this.selectedMassDate.set(massesList[massesList.length - 1].date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class FaceDetectorService {
|
||||
public isTilted = signal<boolean>(false);
|
||||
|
||||
private stream: MediaStream | null = null;
|
||||
private camera: any = null;
|
||||
private animFrameId: number | null = null;
|
||||
private faceMesh: any = null;
|
||||
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
|
||||
|
||||
@@ -17,109 +17,98 @@ export class FaceDetectorService {
|
||||
private tiltStartTime: number = 0;
|
||||
private inCooldown: boolean = false;
|
||||
private lastTriggerTime: number = 0;
|
||||
private readonly TILT_THRESHOLD = 15; // Degrees to trigger next/prev page
|
||||
private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability)
|
||||
private readonly TILT_HOLD_MS = 300; // How long to hold the tilt
|
||||
private readonly RETURN_THRESHOLD = 6; // Degrees to reset cooldown
|
||||
private readonly TRIGGER_COOLDOWN_MS = 5000; // Minimum time between consecutive gestures in ms (5 seconds)
|
||||
private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown
|
||||
private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Loads MediaPipe scripts dynamically if not already loaded.
|
||||
* Loads MediaPipe script dynamically if not already loaded.
|
||||
*/
|
||||
private loadScripts(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).FaceMesh && (window as any).Camera) {
|
||||
if ((window as any).FaceMesh) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraScript = document.createElement('script');
|
||||
cameraScript.src = 'assets/mediapipe/camera_utils.js';
|
||||
|
||||
const faceMeshScript = document.createElement('script');
|
||||
faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
|
||||
|
||||
cameraScript.onload = () => {
|
||||
document.head.appendChild(faceMeshScript);
|
||||
};
|
||||
|
||||
faceMeshScript.onload = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
cameraScript.onerror = (err) => reject(err);
|
||||
faceMeshScript.onload = () => resolve();
|
||||
faceMeshScript.onerror = (err) => reject(err);
|
||||
|
||||
document.head.appendChild(cameraScript);
|
||||
document.head.appendChild(faceMeshScript);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts camera capture and face mesh tracking.
|
||||
* Starts or re-binds camera capture and face mesh tracking.
|
||||
* If a stream is already active (e.g. navigating between songs on iPad), reuses it
|
||||
* to avoid triggering repeated permission prompts on iOS/Safari.
|
||||
*/
|
||||
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> {
|
||||
if (this.isCameraActive()) return;
|
||||
this.onTiltCallback = onTilt;
|
||||
|
||||
try {
|
||||
await this.loadScripts();
|
||||
|
||||
// Request camera permissions and stream
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 320 },
|
||||
height: { ideal: 240 },
|
||||
facingMode: 'user'
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
const isStreamActive = this.stream &&
|
||||
this.stream.active &&
|
||||
this.stream.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!isStreamActive) {
|
||||
// Request camera permissions and stream ONCE
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 320 },
|
||||
height: { ideal: 240 },
|
||||
facingMode: 'user'
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
}
|
||||
|
||||
videoElement.srcObject = this.stream;
|
||||
videoElement.setAttribute('playsinline', 'true');
|
||||
(videoElement as any).playsInline = true;
|
||||
videoElement.muted = true;
|
||||
videoElement.play();
|
||||
|
||||
const FaceMeshLib = (window as any).FaceMesh;
|
||||
const CameraLib = (window as any).Camera;
|
||||
|
||||
if (!FaceMeshLib || !CameraLib) {
|
||||
throw new Error('MediaPipe libraries failed to initialize.');
|
||||
|
||||
try {
|
||||
await videoElement.play();
|
||||
} catch (playErr) {
|
||||
console.warn('[FaceDetector] Video play warning:', playErr);
|
||||
}
|
||||
|
||||
this.faceMesh = new FaceMeshLib({
|
||||
locateFile: (file: string) => `assets/mediapipe/${file}`
|
||||
});
|
||||
if (!this.faceMesh) {
|
||||
const FaceMeshLib = (window as any).FaceMesh;
|
||||
if (!FaceMeshLib) {
|
||||
throw new Error('MediaPipe FaceMesh library failed to initialize.');
|
||||
}
|
||||
|
||||
this.faceMesh.setOptions({
|
||||
maxNumFaces: 1,
|
||||
refineLandmarks: false,
|
||||
minDetectionConfidence: 0.6,
|
||||
minTrackingConfidence: 0.6
|
||||
});
|
||||
this.faceMesh = new FaceMeshLib({
|
||||
locateFile: (file: string) => `assets/mediapipe/${file}`
|
||||
});
|
||||
|
||||
this.faceMesh.onResults((results: any) => {
|
||||
this.processLandmarks(results);
|
||||
});
|
||||
this.faceMesh.setOptions({
|
||||
maxNumFaces: 1,
|
||||
refineLandmarks: false,
|
||||
minDetectionConfidence: 0.6,
|
||||
minTrackingConfidence: 0.6
|
||||
});
|
||||
|
||||
let lastFrameTime = 0;
|
||||
const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM
|
||||
this.camera = new CameraLib(videoElement, {
|
||||
onFrame: async () => {
|
||||
if (this.isCameraActive() && this.faceMesh) {
|
||||
const now = Date.now();
|
||||
if (now - lastFrameTime >= FRAME_INTERVAL_MS) {
|
||||
lastFrameTime = now;
|
||||
await this.faceMesh.send({ image: videoElement });
|
||||
}
|
||||
}
|
||||
},
|
||||
width: 320,
|
||||
height: 240
|
||||
});
|
||||
this.faceMesh.onResults((results: any) => {
|
||||
this.processLandmarks(results);
|
||||
});
|
||||
}
|
||||
|
||||
this.isCameraActive.set(true);
|
||||
await this.camera.start();
|
||||
|
||||
// Start custom frame processing loop
|
||||
this.stopFrameLoop();
|
||||
this.startFrameLoop(videoElement);
|
||||
|
||||
console.log('[FaceDetector] Face tracking started successfully.');
|
||||
} catch (err) {
|
||||
console.error('[FaceDetector] Failed to start face tracking:', err);
|
||||
@@ -128,6 +117,38 @@ export class FaceDetectorService {
|
||||
}
|
||||
}
|
||||
|
||||
private stopFrameLoop() {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startFrameLoop(videoElement: HTMLVideoElement) {
|
||||
let lastFrameTime = 0;
|
||||
const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM
|
||||
|
||||
const processFrame = async () => {
|
||||
if (!this.isCameraActive()) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFrameTime >= FRAME_INTERVAL_MS && this.faceMesh && videoElement && videoElement.readyState >= 2) {
|
||||
lastFrameTime = now;
|
||||
try {
|
||||
await this.faceMesh.send({ image: videoElement });
|
||||
} catch (err) {
|
||||
console.warn('[FaceDetector] Error processing frame:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isCameraActive()) {
|
||||
this.animFrameId = requestAnimationFrame(processFrame);
|
||||
}
|
||||
};
|
||||
|
||||
this.animFrameId = requestAnimationFrame(processFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes landmarks to calculate head tilt angle.
|
||||
*/
|
||||
@@ -188,23 +209,24 @@ export class FaceDetectorService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops camera capture and releases face mesh resources.
|
||||
* Pauses the frame tracking loop without killing the underlying media stream hardware.
|
||||
*/
|
||||
pause() {
|
||||
this.stopFrameLoop();
|
||||
this.isCameraActive.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops camera capture and releases face mesh & hardware stream resources.
|
||||
*/
|
||||
stop() {
|
||||
this.isCameraActive.set(false);
|
||||
this.pause();
|
||||
this.currentTiltAngle.set(0);
|
||||
this.isTilted.set(false);
|
||||
this.inCooldown = false;
|
||||
this.tiltStartTime = 0;
|
||||
this.lastTriggerTime = 0;
|
||||
|
||||
if (this.camera) {
|
||||
try {
|
||||
this.camera.stop();
|
||||
} catch (e) {}
|
||||
this.camera = null;
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
@@ -218,6 +240,6 @@ export class FaceDetectorService {
|
||||
}
|
||||
|
||||
this.onTiltCallback = null;
|
||||
console.log('[FaceDetector] Face tracking stopped.');
|
||||
console.log('[FaceDetector] Face tracking fully stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,11 @@ export class LyricsParserService {
|
||||
isContiguousNext(segments: ChordSegment[], index: number): boolean {
|
||||
if (!segments || index >= segments.length - 1) return false;
|
||||
|
||||
// Se sia il segmento corrente che il successivo hanno un accordo, non sono contigui (vogliamo dello spazio tra loro)
|
||||
if (segments[index].chord && segments[index + 1].chord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the next segment with non-empty text
|
||||
let nextWithText: ChordSegment | null = null;
|
||||
for (let i = index + 1; i < segments.length; i++) {
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Storage } from '@ionic/storage-angular';
|
||||
import { Canto, CantiService } from './canti.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { ToastController, AlertController } from '@ionic/angular';
|
||||
import { ToastController, AlertController, ModalController } from '@ionic/angular';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { SharePlaylistQrModalComponent } from '../components/share-playlist-qr-modal/share-playlist-qr-modal.component';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { LyricsParserService } from './lyrics-parser.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -13,9 +15,11 @@ import { MyCantiService } from './my-canti.service';
|
||||
export class PlaylistService {
|
||||
private storage = inject(Storage);
|
||||
private cantiService = inject(CantiService);
|
||||
private lyricsParser = inject(LyricsParserService);
|
||||
private comunitaService = inject(ComunitaService);
|
||||
private toastCtrl = inject(ToastController);
|
||||
private alertCtrl = inject(AlertController);
|
||||
private modalCtrl = inject(ModalController);
|
||||
private settingsService = inject(SettingsService);
|
||||
private injector = inject(Injector);
|
||||
private myCantiService!: MyCantiService;
|
||||
@@ -29,6 +33,7 @@ export class PlaylistService {
|
||||
public activeListIds = signal<string[]>([]);
|
||||
public activeListName = signal<string | null>(null);
|
||||
public activePlaylistId = signal<string | null>(null);
|
||||
public filteredListIds = signal<string[]>([]);
|
||||
|
||||
public remotePlaylist = signal<any | null>(null);
|
||||
public remoteCustomSongs = signal<any[]>([]);
|
||||
@@ -665,108 +670,26 @@ export class PlaylistService {
|
||||
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}&openFirst=1`;
|
||||
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}&openFirst=1`;
|
||||
}
|
||||
this.executeShare(shareLink, name);
|
||||
}
|
||||
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
|
||||
if (isRemote) {
|
||||
let remoteUid = uid;
|
||||
let remotePid = activeId;
|
||||
const parts = activeId.split('_');
|
||||
if (parts.length >= 3) {
|
||||
remoteUid = parts[1];
|
||||
remotePid = parts[2];
|
||||
}
|
||||
];
|
||||
|
||||
if (!isRemote) {
|
||||
buttons.push({
|
||||
text: 'Modifica (Collaborazione / Backup)',
|
||||
handler: () => {
|
||||
const shareLink = `https://www.canticristiani.it/?restore-uid=${uid}`;
|
||||
this.executeShare(shareLink, name + ' (Editor)');
|
||||
}
|
||||
});
|
||||
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
|
||||
}
|
||||
|
||||
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 modal = await this.modalCtrl.create({
|
||||
component: SharePlaylistQrModalComponent,
|
||||
componentProps: {
|
||||
playlistName: name,
|
||||
shareLink: shareLink
|
||||
}
|
||||
});
|
||||
|
||||
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||
|
||||
try {
|
||||
const res = await fetch(qrImage);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Playlist CantiCristiani',
|
||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
||||
});
|
||||
} else {
|
||||
// Fallback to simple share text or copy/download
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: 'Playlist CantiCristiani',
|
||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
|
||||
});
|
||||
} else {
|
||||
// Fallback: copia il link negli appunti e scarica l'immagine del QR
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Link playlist copiato negli appunti! QR Code scaricato.',
|
||||
duration: 3000,
|
||||
color: 'success'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
} catch (clipErr) {
|
||||
console.warn('Failed to copy link to clipboard:', clipErr);
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = qrImage;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Share failed', err);
|
||||
}
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async loadPlaylists() {
|
||||
@@ -792,4 +715,559 @@ export class PlaylistService {
|
||||
this.remoteShareCanti.set(updated);
|
||||
await this._storage?.set('remote_share_canti', updated);
|
||||
}
|
||||
|
||||
public printPlaylist(playlistName: string, songs: any[], showChords: boolean, transpositions?: { [songId: string]: number }) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
alert('Impossibile aprire la finestra di stampa. Abilita i popup nel browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.myCantiService) {
|
||||
this.myCantiService = this.injector.get(MyCantiService);
|
||||
}
|
||||
|
||||
const titleHtml = playlistName ? `
|
||||
<div class="cover-page">
|
||||
<div class="cover-border">
|
||||
<h1>Playlist: ${playlistName}</h1>
|
||||
<p class="meta">Data: ${new Date().toLocaleDateString('it-IT')} • N. Canti: ${songs.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
let contentHtml = '';
|
||||
|
||||
for (const song of songs) {
|
||||
if (!song) continue;
|
||||
const isPersonal = song.id && song.id.startsWith('my_');
|
||||
let songNum = '';
|
||||
if (isPersonal) {
|
||||
const myIndex = this.myCantiService ? this.myCantiService.myCanti().findIndex((c: any) => c.id === song.id) : -1;
|
||||
songNum = myIndex !== -1 ? `Pers. ${myIndex + 1}` : 'Pers.';
|
||||
} else {
|
||||
songNum = song.id_canti || '';
|
||||
}
|
||||
|
||||
// Check if there is a community number
|
||||
const commCode = this.comunitaService.comunitaCode();
|
||||
const commActive = this.comunitaService.isFilterActive();
|
||||
let commNum = '';
|
||||
if (commCode && commActive) {
|
||||
const cantiInfo = this.comunitaService.comunitaCantiInfo();
|
||||
const info = cantiInfo.find((x: any) => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
|
||||
if (info && info.num_canto) {
|
||||
commNum = info.num_canto.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const displayNum = commNum ? `${commNum} (${songNum})` : songNum;
|
||||
|
||||
// Determine the transposition amount (semitones)
|
||||
let semitones = 0;
|
||||
if (transpositions && transpositions[song.id] !== undefined) {
|
||||
semitones = transpositions[song.id];
|
||||
} else if (transpositions && transpositions[song.id_canti] !== undefined) {
|
||||
semitones = transpositions[song.id_canti];
|
||||
} else {
|
||||
// Fallback to active playlist settings or community settings
|
||||
const activePlaylistId = this.activePlaylistId();
|
||||
let playlistSongSetting: any = null;
|
||||
if (activePlaylistId) {
|
||||
if (activePlaylistId.startsWith('remote_')) {
|
||||
const pl = this.remotePlaylist();
|
||||
if (pl && pl.songSettings && pl.songSettings[song.id]) {
|
||||
playlistSongSetting = pl.songSettings[song.id];
|
||||
}
|
||||
} else {
|
||||
const pl = this.playlists().find(p => p.id === activePlaylistId);
|
||||
if (pl && pl.songSettings && pl.songSettings[song.id]) {
|
||||
playlistSongSetting = pl.songSettings[song.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistSongSetting && playlistSongSetting.tonalita !== undefined) {
|
||||
semitones = playlistSongSetting.tonalita;
|
||||
} else if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||
const settings = this.comunitaService.comunitaCantiSettings();
|
||||
const songSetting = settings.find(s => s.id_canti === song.id_canti || s.id_canti === Number(song.id));
|
||||
if (songSetting && songSetting.tonalita !== undefined) {
|
||||
semitones = songSetting.tonalita;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawText = showChords ? (song.accordi || song.testo) : song.testo;
|
||||
let parsedSections = showChords ? this.lyricsParser.parseAccordi(rawText) : this.lyricsParser.parseText(rawText);
|
||||
if (showChords && semitones !== 0) {
|
||||
parsedSections = this.lyricsParser.transposeSections(parsedSections, semitones);
|
||||
}
|
||||
|
||||
let songBodyHtml = '';
|
||||
for (const section of parsedSections) {
|
||||
const sectionClass = section.type === 'chorus' ? 'section-chorus' : 'section-verse';
|
||||
let sectionHeader = '';
|
||||
if (section.type === 'verse_num' && section.verseNumber) {
|
||||
sectionHeader = `<span class="verse-number">${section.verseNumber}.</span>`;
|
||||
}
|
||||
|
||||
let linesHtml = '';
|
||||
for (const line of section.lines) {
|
||||
let lineContentHtml = '';
|
||||
if (showChords) {
|
||||
for (let i = 0; i < line.segments.length; i++) {
|
||||
const seg = line.segments[i];
|
||||
const isCont = this.lyricsParser.isContiguousNext(line.segments, i);
|
||||
const chordHtml = seg.chord ? `<span class="chord">${seg.chord}</span>` : '';
|
||||
lineContentHtml += `<span class="chord-segment${isCont ? ' contiguous-next' : ''}">${chordHtml}<span class="seg-text">${seg.text || ''}</span></span>`;
|
||||
}
|
||||
} else {
|
||||
lineContentHtml = line.text;
|
||||
}
|
||||
linesHtml += `<div class="lyric-line">${lineContentHtml}</div>`;
|
||||
}
|
||||
|
||||
songBodyHtml += `<div class="section-container ${sectionClass}">${sectionHeader}${linesHtml}</div>`;
|
||||
}
|
||||
|
||||
contentHtml += `
|
||||
<div class="canto-container">
|
||||
<div class="canto-header">
|
||||
<div class="canto-title-row">
|
||||
<span class="canto-number">${displayNum}</span>
|
||||
<span class="canto-title">${song.titolo}</span>
|
||||
</div>
|
||||
<div class="canto-meta">
|
||||
${song.autore ? `<span>Autore: ${song.autore}</span>` : ''}
|
||||
${song.durata ? `<span>Durata: ${song.durata}</span>` : ''}
|
||||
${song.bpm ? `<span>BPM: ${song.bpm}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="canto-body">
|
||||
${songBodyHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${playlistName || 'Playlist'}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1cm;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.cover-page {
|
||||
page-break-after: always;
|
||||
width: 27.7cm;
|
||||
height: 18.8cm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 1.5cm;
|
||||
}
|
||||
.cover-border {
|
||||
border: 2px solid #cc6600;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cover-border h1 {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: #cc6600;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
.cover-border .meta {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
.canto-container {
|
||||
page-break-after: always;
|
||||
width: 27.7cm;
|
||||
height: 18.8cm;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.canto-container:last-child {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.canto-header {
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 2px solid #cc6600;
|
||||
padding-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.canto-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.canto-number {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #cc6600;
|
||||
}
|
||||
.canto-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
.canto-meta {
|
||||
font-size: 13px;
|
||||
color: #777;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.canto-body {
|
||||
flex-grow: 1;
|
||||
column-count: 2;
|
||||
column-gap: 40px;
|
||||
column-fill: auto;
|
||||
overflow: hidden;
|
||||
line-height: 1.45;
|
||||
font-size: 27px; /* Aumentato di 0.5em rispetto al valore iniziale di 18px per leggibilità a distanza */
|
||||
}
|
||||
.section-container {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.section-chorus {
|
||||
border-left: 3.5px solid #cc6600;
|
||||
padding-left: 14px;
|
||||
margin-left: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
.verse-number {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 800;
|
||||
color: #cc6600;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
font-size: 18px; /* Scalato per font-size 27px */
|
||||
}
|
||||
.lyric-line {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.chord-segment {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.22em;
|
||||
}
|
||||
.chord-segment.contiguous-next {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.chord {
|
||||
font-size: 0.82em;
|
||||
font-weight: 700;
|
||||
color: #b35900;
|
||||
height: 1.3em;
|
||||
margin-bottom: -0.1em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.seg-text {
|
||||
white-space: pre;
|
||||
.close-btn-web {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #cc6600;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 9999;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.close-btn-web:hover {
|
||||
background: #b35900;
|
||||
}
|
||||
@media print {
|
||||
@page {
|
||||
margin: 1cm;
|
||||
size: A4 landscape;
|
||||
}
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
.canto-container {
|
||||
height: 18.8cm;
|
||||
}
|
||||
.close-btn-web {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button onclick="window.close()" class="close-btn-web">Chiudi</button>
|
||||
${titleHtml}
|
||||
${contentHtml}
|
||||
<script>
|
||||
window.onload = () => {
|
||||
const containers = Array.from(document.querySelectorAll('.canto-container'));
|
||||
|
||||
containers.forEach(container => {
|
||||
const baseBody = container.querySelector('.canto-body');
|
||||
if (!baseBody) return;
|
||||
|
||||
// Salva l'HTML originale per poterlo ripristinare intatto a ogni ciclo di ridimensionamento
|
||||
let originalHtml = container.getAttribute('data-original-html');
|
||||
if (!originalHtml) {
|
||||
originalHtml = baseBody.innerHTML;
|
||||
container.setAttribute('data-original-html', originalHtml);
|
||||
}
|
||||
|
||||
let fontSize = 27; // Base 27px (+0.5em)
|
||||
|
||||
const overflows = (el) => {
|
||||
return el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
||||
};
|
||||
|
||||
const doLayoutSplit = () => {
|
||||
// Rimuovi eventuali pagine dinamiche create in precedenza per questo canto
|
||||
let next = container.nextSibling;
|
||||
while (next && next.classList && next.classList.contains('dynamic-canto-page')) {
|
||||
const toRemove = next;
|
||||
next = next.nextSibling;
|
||||
toRemove.remove();
|
||||
}
|
||||
|
||||
// Ripristina l'HTML originale intatto nella prima pagina
|
||||
baseBody.innerHTML = originalHtml;
|
||||
|
||||
container.style.fontSize = fontSize + 'px';
|
||||
baseBody.style.fontSize = fontSize + 'px';
|
||||
|
||||
const pages = [{ container, body: baseBody }];
|
||||
let currentPageIndex = 0;
|
||||
|
||||
// Distribuisci il testo riga per riga (o sezione per sezione) creando nuove pagine
|
||||
while (currentPageIndex < pages.length) {
|
||||
const currentPage = pages[currentPageIndex];
|
||||
|
||||
if (overflows(currentPage.body)) {
|
||||
let nextPageIndex = currentPageIndex + 1;
|
||||
let nextPage = pages[nextPageIndex];
|
||||
|
||||
if (!nextPage) {
|
||||
const newPageContainer = document.createElement('div');
|
||||
newPageContainer.className = 'canto-container dynamic-canto-page';
|
||||
|
||||
const header = container.querySelector('.canto-header').cloneNode(true);
|
||||
newPageContainer.appendChild(header);
|
||||
|
||||
const newBody = document.createElement('div');
|
||||
newBody.className = 'canto-body';
|
||||
newPageContainer.appendChild(newBody);
|
||||
|
||||
// Inserisci dopo l'ultima pagina inserita
|
||||
const lastPageContainer = pages[pages.length - 1].container;
|
||||
lastPageContainer.parentNode.insertBefore(newPageContainer, lastPageContainer.nextSibling);
|
||||
|
||||
nextPage = { container: newPageContainer, body: newBody };
|
||||
pages.push(nextPage);
|
||||
}
|
||||
|
||||
// Imposta font-size coerente sulla nuova pagina
|
||||
nextPage.container.style.fontSize = fontSize + 'px';
|
||||
nextPage.body.style.fontSize = fontSize + 'px';
|
||||
|
||||
// Sposta elementi riga per riga per sfruttare al millimetro lo spazio
|
||||
let safetyCounter = 0;
|
||||
while (overflows(currentPage.body) && safetyCounter < 300) {
|
||||
safetyCounter++;
|
||||
const childSections = Array.from(currentPage.body.children);
|
||||
if (childSections.length === 0) break;
|
||||
|
||||
const lastSection = childSections[childSections.length - 1];
|
||||
const lyricLines = Array.from(lastSection.querySelectorAll('.lyric-line'));
|
||||
|
||||
// Recupera o assegna un ID univoco a questa sezione
|
||||
let sectionId = lastSection.getAttribute('data-section-id');
|
||||
if (!sectionId) {
|
||||
sectionId = 'sec-' + Math.random().toString(36).substr(2, 9);
|
||||
lastSection.setAttribute('data-section-id', sectionId);
|
||||
}
|
||||
|
||||
// Cerca se esiste già la sezione corrispondente sulla pagina successiva
|
||||
let targetSection = nextPage.body.querySelector('[data-section-id="' + sectionId + '"]');
|
||||
if (!targetSection) {
|
||||
targetSection = document.createElement('div');
|
||||
targetSection.className = lastSection.className;
|
||||
targetSection.setAttribute('data-section-id', sectionId);
|
||||
nextPage.body.insertBefore(targetSection, nextPage.body.firstChild);
|
||||
}
|
||||
|
||||
if (lyricLines.length > 1) {
|
||||
// Spezza la sezione: sposta solo l'ultima riga all'inizio di targetSection
|
||||
const lastLine = lyricLines[lyricLines.length - 1];
|
||||
targetSection.insertBefore(lastLine, targetSection.firstChild);
|
||||
} else {
|
||||
// Sposta tutti i restanti nodi (inclusi eventuali numeri di strofa o l'unica riga rimasta)
|
||||
while (lastSection.firstChild) {
|
||||
targetSection.insertBefore(lastSection.lastChild, targetSection.firstChild);
|
||||
}
|
||||
lastSection.remove();
|
||||
}
|
||||
|
||||
// Evita loop infiniti se una singola riga è più grande dell'intera pagina
|
||||
const remaining = Array.from(currentPage.body.children);
|
||||
if (remaining.length === 1) {
|
||||
const remLines = remaining[0].querySelectorAll('.lyric-line');
|
||||
if (remLines.length <= 1) {
|
||||
// Sposta gli ultimi frammenti rimasti
|
||||
let lastSec = remaining[0];
|
||||
let secId = lastSec.getAttribute('data-section-id') || 'sec-last';
|
||||
let finalTarget = nextPage.body.querySelector('[data-section-id="' + secId + '"]');
|
||||
if (!finalTarget) {
|
||||
finalTarget = document.createElement('div');
|
||||
finalTarget.className = lastSec.className;
|
||||
finalTarget.setAttribute('data-section-id', secId);
|
||||
nextPage.body.insertBefore(finalTarget, nextPage.body.firstChild);
|
||||
}
|
||||
while (lastSec.firstChild) {
|
||||
finalTarget.insertBefore(lastSec.lastChild, finalTarget.firstChild);
|
||||
}
|
||||
lastSec.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPageIndex++;
|
||||
}
|
||||
|
||||
// Aggiorna le intestazioni con l'indice di pagina (es. 1 di 3, 2 di 3...)
|
||||
const totalPages = pages.length;
|
||||
pages.forEach((p, idx) => {
|
||||
const titleElem = p.container.querySelector('.canto-title');
|
||||
if (titleElem) {
|
||||
let origTitle = titleElem.getAttribute('data-original-title');
|
||||
if (!origTitle) {
|
||||
origTitle = titleElem.innerHTML.split('<span')[0].trim();
|
||||
titleElem.setAttribute('data-original-title', origTitle);
|
||||
}
|
||||
if (totalPages > 1) {
|
||||
titleElem.innerHTML = origTitle + ' <span style="font-size: 0.65em; color: #777; font-weight: 400;">(' + (idx + 1) + ' di ' + totalPages + ')</span>';
|
||||
} else {
|
||||
titleElem.innerHTML = origTitle;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getSongPages = () => {
|
||||
const pages = [container];
|
||||
let next = container.nextSibling;
|
||||
while (next && next.classList && next.classList.contains('dynamic-canto-page')) {
|
||||
pages.push(next);
|
||||
next = next.nextSibling;
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
// Esegui la suddivisione. Se qualcuna delle pagine risultanti trabocca, o
|
||||
// se il canto supera il totale delle pagine massime consigliate, o
|
||||
// se l'ultima pagina contiene solo 1 o 2 righe (orfane), riduci gradualmente il font size.
|
||||
const checkAnyOverflow = () => {
|
||||
const songPages = getSongPages();
|
||||
|
||||
// 1. Controlla se una pagina qualsiasi trabocca internamente
|
||||
const hasInternalOverflow = songPages.some(p => {
|
||||
const b = p.querySelector('.canto-body');
|
||||
return b && overflows(b);
|
||||
});
|
||||
if (hasInternalOverflow) return true;
|
||||
|
||||
// 2. Limite pagine dinamico: preferisci massimo 3 pagine, consenti 4 o 5 solo se il carattere è già piccolo
|
||||
let maxAllowedPages = 3;
|
||||
if (fontSize <= 17) {
|
||||
maxAllowedPages = 4;
|
||||
}
|
||||
if (fontSize <= 14) {
|
||||
maxAllowedPages = 5;
|
||||
}
|
||||
if (songPages.length > maxAllowedPages) {
|
||||
return true; // Supera il limite massimo consigliato per questo font size, forza la riduzione del font
|
||||
}
|
||||
|
||||
// 3. Se ci sono più pagine, controlla se l'ultima pagina ha solo 1 o 2 righe di testo
|
||||
if (songPages.length > 1) {
|
||||
const lastPage = songPages[songPages.length - 1];
|
||||
const lastPageBody = lastPage.querySelector('.canto-body');
|
||||
if (lastPageBody) {
|
||||
const lyricLines = lastPageBody.querySelectorAll('.lyric-line').length;
|
||||
if (lyricLines > 0 && lyricLines <= 2) {
|
||||
return true; // Forza la riduzione del font per tirare su queste righe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
doLayoutSplit();
|
||||
|
||||
while (checkAnyOverflow() && fontSize > 12) {
|
||||
fontSize -= 0.5;
|
||||
doLayoutSplit();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 600);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
printWindow.document.open();
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ export class SettingsService {
|
||||
/** Attiva autoscroll visuale nel dettaglio canto: true = attivo */
|
||||
public enableVisualAutoscroll = signal<boolean>(true);
|
||||
|
||||
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
|
||||
public cameraNavigationActive = signal<boolean>(false);
|
||||
|
||||
/** Preferenza notazione accordi: diesis o bemolle */
|
||||
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
||||
|
||||
@@ -137,6 +140,8 @@ export class SettingsService {
|
||||
this.deferredPrompt.set(e);
|
||||
// Update UI notify the user they can install the PWA
|
||||
this.showInstallButton.set(true);
|
||||
// Se scatta l'evento di installazione, l'app NON è attualmente installata
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
@@ -355,6 +360,17 @@ export class SettingsService {
|
||||
localStorage.setItem('global-zoom', this.globalZoom().toString());
|
||||
});
|
||||
|
||||
const savedCameraNavActive = localStorage.getItem('camera-navigation-active');
|
||||
if (savedCameraNavActive !== null) {
|
||||
this.cameraNavigationActive.set(savedCameraNavActive === 'true');
|
||||
} else {
|
||||
this.cameraNavigationActive.set(false);
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const active = this.keepScreenOn();
|
||||
localStorage.setItem('keep-screen-on', active.toString());
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.07.14.1946';
|
||||
export const VERSION = '2026.08.08.1614';
|
||||
|
||||
+8
-3
@@ -1,8 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="it" class="notranslate">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="google" content="notranslate"/>
|
||||
<title>CantiCristiani</title>
|
||||
|
||||
<base href="/"/>
|
||||
@@ -73,7 +74,11 @@
|
||||
if (options.percent !== undefined) {
|
||||
const pct = Math.min(100, Math.max(0, Math.round(options.percent)));
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
if (pctText) pctText.textContent = pct + '%';
|
||||
if (bar && bar.parentElement) bar.parentElement.style.display = 'block';
|
||||
if (pctText) {
|
||||
pctText.style.display = 'block';
|
||||
pctText.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isRedirect) {
|
||||
@@ -82,7 +87,7 @@
|
||||
if (phaseEl) phaseEl.style.display = 'none';
|
||||
if (versionEl) versionEl.style.display = 'none';
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device';
|
||||
titleEl.textContent = 'Chiudi il browser è stata installata la app sul device';
|
||||
titleEl.style.fontSize = '1.4rem';
|
||||
titleEl.style.lineHeight = '1.5';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user