fix: risoluzione importazione playlist tramite link di condivisione e fix installabilità PWA su Android
@@ -1,119 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# --- Caricamento variabili d'ambiente ---
|
|
||||||
if [ -f .env ]; then
|
|
||||||
export $(grep -v '^#' .env | xargs)
|
|
||||||
else
|
|
||||||
echo "❌ Errore: File .env non trovato."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Configurazione Parallelismo ---
|
|
||||||
THREADS=${1:-${FTP_THREADS:-30}}
|
|
||||||
|
|
||||||
# --- Configurazione FTP ---
|
|
||||||
FTP_HOST="ftp.canticristiani.it"
|
|
||||||
FTP_USER="canticristiani.it"
|
|
||||||
FTP_PASS="$FTP_PASSWORD"
|
|
||||||
REMOTE_DIR="${BASE_HREF%/}" # Rimuove lo slash finale se presente per la directory remota
|
|
||||||
|
|
||||||
if [ -z "$FTP_PASS" ]; then
|
|
||||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Aggiornamento Versione ---
|
|
||||||
VERSION=$(date +'%Y.%m.%d.%H%M')
|
|
||||||
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
|
||||||
echo "🏷️ Versione aggiornata a: $VERSION"
|
|
||||||
|
|
||||||
# --- Configurazione Email Parametrica ---
|
|
||||||
node -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
|
||||||
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
|
|
||||||
if (fs.existsSync(file)) {
|
|
||||||
let content = fs.readFileSync(file, 'utf8');
|
|
||||||
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
|
|
||||||
fs.writeFileSync(file, content, 'utf8');
|
|
||||||
console.log(\`📧 Aggiornata email di contatto in \${file} a: \${envEmail}\`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
"
|
|
||||||
|
|
||||||
# --- Build della PWA ---
|
|
||||||
echo "📦 1/2 Build della PWA (Production) in corso..."
|
|
||||||
# Usiamo la variabile d'ambiente BASE_HREF dal file .env
|
|
||||||
npm run build -- --configuration=production --base-href=$BASE_HREF || { echo "❌ Errore nella build"; exit 1; }
|
|
||||||
|
|
||||||
if [ ! -d "www" ]; then
|
|
||||||
echo "❌ Errore: Cartella 'www' non trovata."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Upload via FTP ---
|
|
||||||
echo "🚀 2/2 Caricamento parallelo ($THREADS connessioni) su $FTP_HOST$REMOTE_DIR..."
|
|
||||||
cd www
|
|
||||||
|
|
||||||
TOTAL_FILES=$(find . -type f | wc -l | xargs)
|
|
||||||
PROGRESS_LOG=$(mktemp)
|
|
||||||
ERROR_LOG=$(mktemp)
|
|
||||||
|
|
||||||
show_progress() {
|
|
||||||
local current=0
|
|
||||||
while [ "$current" -lt "$TOTAL_FILES" ]; do
|
|
||||||
current=$(wc -l < "$PROGRESS_LOG" | xargs)
|
|
||||||
local percent=$((current * 100 / TOTAL_FILES))
|
|
||||||
local bar_size=20
|
|
||||||
local num_hash=$((percent * bar_size / 100))
|
|
||||||
local bar=$(printf "%${num_hash}s" | tr ' ' '#' 2>/dev/null)
|
|
||||||
local spaces=$(printf "%$((bar_size - num_hash))s" | tr ' ' '-')
|
|
||||||
printf "\r[%-20s] %d%% (%d/%d) caricati..." "$bar$spaces" "$percent" "$current" "$TOTAL_FILES"
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
show_progress &
|
|
||||||
BAR_PID=$!
|
|
||||||
|
|
||||||
# Lancio dei job paralleli
|
|
||||||
find . -type f | while read -r file; do
|
|
||||||
REMOTE_FILE_PATH=${file#./}
|
|
||||||
|
|
||||||
# Eseguiamo curl e segniamo SEMPRE il progresso (anche se fallisce)
|
|
||||||
# Se fallisce, scriviamo il nome del file nell'error log
|
|
||||||
(
|
|
||||||
# Usiamo --retry per gestire errori temporanei di connessione
|
|
||||||
if ! curl -s --retry 3 --retry-delay 1 --connect-timeout 10 -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$file" "ftp://$FTP_HOST$REMOTE_DIR/$REMOTE_FILE_PATH"; then
|
|
||||||
echo "$REMOTE_FILE_PATH" >> "$ERROR_LOG"
|
|
||||||
fi
|
|
||||||
echo 1 >> "$PROGRESS_LOG"
|
|
||||||
) &
|
|
||||||
|
|
||||||
# Gestione THREADS
|
|
||||||
while [ $(jobs -r | wc -l) -ge "$THREADS" ]; do
|
|
||||||
sleep 0.05
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
# Attendi fine caricamenti
|
|
||||||
wait
|
|
||||||
|
|
||||||
# Stop barra
|
|
||||||
sleep 0.5
|
|
||||||
kill $BAR_PID 2>/dev/null
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Controllo errori
|
|
||||||
ERRORS=$(wc -l < "$ERROR_LOG" | xargs)
|
|
||||||
if [ "$ERRORS" -gt 0 ]; then
|
|
||||||
echo "⚠️ Deploy completato con $ERRORS errori."
|
|
||||||
echo "I seguenti file non sono stati caricati:"
|
|
||||||
cat "$ERROR_LOG"
|
|
||||||
else
|
|
||||||
echo "✅ Deploy completato con successo senza errori!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
rm "$PROGRESS_LOG" "$ERROR_LOG"
|
|
||||||
printf "L'app è disponibile su http://canticristiani.it${BASE_HREF}\n"
|
|
||||||
@@ -17,6 +17,7 @@ VERSION=$(date +'%Y.%m.%d.%H%M')
|
|||||||
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||||
echo "🏷️ Versione aggiornata a: $VERSION"
|
echo "🏷️ Versione aggiornata a: $VERSION"
|
||||||
|
|
||||||
|
|
||||||
# 2. Build dell'applicazione
|
# 2. Build dell'applicazione
|
||||||
echo "📦 Compilazione in corso (Production Build)..."
|
echo "📦 Compilazione in corso (Production Build)..."
|
||||||
if ! npm run build -- --configuration=production --base-href=/; then
|
if ! npm run build -- --configuration=production --base-href=/; then
|
||||||
@@ -54,7 +55,7 @@ echo "--------------------------------------------------------"
|
|||||||
echo "📱 INSTALLAZIONE SU CELLULARE:"
|
echo "📱 INSTALLAZIONE SU CELLULARE:"
|
||||||
echo "1. Assicurati che il cellulare sia sulla stessa rete Wi-Fi."
|
echo "1. Assicurati che il cellulare sia sulla stessa rete Wi-Fi."
|
||||||
echo "2. Apri il browser (Chrome su Android, Safari su iOS)."
|
echo "2. Apri il browser (Chrome su Android, Safari su iOS)."
|
||||||
echo "3. Vai all'indirizzo: http://$IP_ADDRESS:8080/ionic/"
|
echo "3. Vai all'indirizzo: http://\$IP_ADDRESS:8080/"
|
||||||
echo " ⚠️ NOTA: Per usare il MICROFONO su cellulare, i browser richiedono HTTPS."
|
echo " ⚠️ NOTA: Per usare il MICROFONO su cellulare, i browser richiedono HTTPS."
|
||||||
echo " Puoi usare uno strumento come 'ngrok' o 'local-ssl-proxy' per testarlo con HTTPS."
|
echo " Puoi usare uno strumento come 'ngrok' o 'local-ssl-proxy' per testarlo con HTTPS."
|
||||||
echo "4. Una volta caricata l'app:"
|
echo "4. Una volta caricata l'app:"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
|||||||
});
|
});
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|
||||||
# --- Build della PWA (base-href=/ per installare nella root) ---
|
# --- Build della PWA (base-href=/ per installare nella root) ---
|
||||||
echo "📦 1/2 Build della PWA (Production) per ROOT (www.canticristiani.it) in corso..."
|
echo "📦 1/2 Build della PWA (Production) per ROOT (www.canticristiani.it) in corso..."
|
||||||
npm run build -- --configuration=production --base-href=/ || { echo "❌ Errore nella build"; exit 1; }
|
npm run build -- --configuration=production --base-href=/ || { echo "❌ Errore nella build"; exit 1; }
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"installMode": "prefetch",
|
"installMode": "prefetch",
|
||||||
"resources": {
|
"resources": {
|
||||||
"files": [
|
"files": [
|
||||||
"/favicon.ico",
|
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
"/*.css",
|
"/*.css",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Se il file o la directory richiesti esistono, li serve direttamente
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
|
||||||
|
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
</IfModule>
|
||||||
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -2,56 +2,70 @@
|
|||||||
"name": "CantiCristiani",
|
"name": "CantiCristiani",
|
||||||
"short_name": "CantiCristiani",
|
"short_name": "CantiCristiani",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"scope": "./",
|
"scope": "/",
|
||||||
"start_url": "./",
|
"start_url": "/",
|
||||||
|
"theme_color": "#3880ff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/icon-72x72.png",
|
"src": "assets/icons/icon-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-96x96.png",
|
"src": "assets/icons/icon-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-128x128.png",
|
"src": "assets/icons/icon-128x128.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-144x144.png",
|
"src": "assets/icons/icon-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-152x152.png",
|
"src": "assets/icons/icon-152x152.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-192x192.png",
|
"src": "assets/icons/icon-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-384x384.png",
|
"src": "assets/icons/icon-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-512x512.png",
|
"src": "assets/icons/icon-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,21 +19,31 @@ export class AppComponent {
|
|||||||
|
|
||||||
private setupUpdates() {
|
private setupUpdates() {
|
||||||
if (this.swUpdate.isEnabled) {
|
if (this.swUpdate.isEnabled) {
|
||||||
// 1. Check for updates immediately as soon as the application stabilizes
|
// Wait for the application to stabilize before running update checks or starting intervals
|
||||||
const appIsStable$ = this.appRef.isStable.pipe(
|
this.appRef.isStable.pipe(
|
||||||
first(isStable => isStable === true)
|
filter(stable => stable),
|
||||||
);
|
first()
|
||||||
|
).subscribe(() => {
|
||||||
|
console.log('[PWA-Update] App is stable. Initializing update checks...');
|
||||||
|
|
||||||
appIsStable$.subscribe(async () => {
|
// 1. Check for updates immediately
|
||||||
console.log('[PWA-Update] App is stable, checking for PWA updates immediately...');
|
this.swUpdate.checkForUpdate().catch(err => {
|
||||||
|
console.warn('[PWA-Update] Failed immediate startup update check:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Periodic check in background every 30 seconds
|
||||||
|
const every30Seconds$ = interval(30 * 1000);
|
||||||
|
every30Seconds$.subscribe(async () => {
|
||||||
|
console.log('[PWA-Update] Periodic check for updates (every 30s)...');
|
||||||
try {
|
try {
|
||||||
await this.swUpdate.checkForUpdate();
|
await this.swUpdate.checkForUpdate();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[PWA-Update] Failed startup update check:', err);
|
console.warn('[PWA-Update] Failed periodic update check:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Check for updates when the app is resumed/focused
|
// 3. Check for updates when the app is resumed/focused
|
||||||
fromEvent(document, 'visibilitychange')
|
fromEvent(document, 'visibilitychange')
|
||||||
.pipe(filter(() => document.visibilityState === 'visible'))
|
.pipe(filter(() => document.visibilityState === 'visible'))
|
||||||
.subscribe(async () => {
|
.subscribe(async () => {
|
||||||
@@ -45,17 +55,6 @@ export class AppComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Periodic check in background every 5 minutes
|
|
||||||
const everyFiveMinutes$ = interval(5 * 60 * 1000);
|
|
||||||
everyFiveMinutes$.subscribe(async () => {
|
|
||||||
console.log('[PWA-Update] Periodic check for updates...');
|
|
||||||
try {
|
|
||||||
await this.swUpdate.checkForUpdate();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[PWA-Update] Failed periodic update check:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Activate update and reload when a new version is ready
|
// 4. Activate update and reload when a new version is ready
|
||||||
this.swUpdate.versionUpdates
|
this.swUpdate.versionUpdates
|
||||||
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import { ServiceWorkerModule } from '@angular/service-worker';
|
|||||||
IonicStorageModule.forRoot(),
|
IonicStorageModule.forRoot(),
|
||||||
ServiceWorkerModule.register('ngsw-worker.js', {
|
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: !isDevMode(),
|
||||||
// Register the ServiceWorker as soon as the application is stable
|
// Register the ServiceWorker as soon as possible
|
||||||
// or after 30 seconds (whichever comes first).
|
|
||||||
registrationStrategy: 'registerImmediately'
|
registrationStrategy: 'registerImmediately'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="header-logo-wrapper">
|
<div class="header-logo-wrapper">
|
||||||
<img src="assets/icon/favicon.png" class="header-logo">
|
<img src="assets/icon/favicon.png" class="header-logo">
|
||||||
<div class="header-text-group">
|
<div class="header-text-group">
|
||||||
<span class="app-name">CantiCristiani</span>
|
<span class="app-name">{{ appName }}</span>
|
||||||
<span class="version-badge">v{{ version }}</span>
|
<span class="version-badge">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,6 +192,29 @@
|
|||||||
(click)="onInteraction()">
|
(click)="onInteraction()">
|
||||||
|
|
||||||
<div class="ion-padding no-padding-top">
|
<div class="ion-padding no-padding-top">
|
||||||
|
<!-- Premium PWA Android Install Banner -->
|
||||||
|
<div class="pwa-android-banner glass ion-margin-bottom" *ngIf="showAndroidBanner()">
|
||||||
|
<div class="banner-inner">
|
||||||
|
<div class="banner-icon-wrapper">
|
||||||
|
<ion-icon name="cloud-download-outline" class="banner-icon"></ion-icon>
|
||||||
|
</div>
|
||||||
|
<div class="banner-text-wrapper">
|
||||||
|
<h3 class="banner-title outfit-font">Installa la nostra App!</h3>
|
||||||
|
<p class="banner-subtitle outfit-font">
|
||||||
|
Accedi a tutti i canti all'istante, anche offline, direttamente dalla tua home screen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<ion-button fill="solid" size="small" color="secondary" class="install-action-btn outfit-font" (click)="installAndroidPwa()">
|
||||||
|
Installa
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" class="dismiss-action-btn" (click)="dismissAndroidBanner($event)">
|
||||||
|
<ion-icon name="close-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
|
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
|
||||||
<div class="loading-wrapper">
|
<div class="loading-wrapper">
|
||||||
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
||||||
@@ -394,3 +417,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-footer>
|
</ion-footer>
|
||||||
|
|
||||||
|
<!-- Premium PWA iOS Tooltip -->
|
||||||
|
<div class="pwa-ios-tooltip-wrapper" [class.has-player]="youtubePlayerService.currentCantoId()" *ngIf="showIosTooltip()">
|
||||||
|
<div class="pwa-ios-tooltip glass">
|
||||||
|
<div class="tooltip-header">
|
||||||
|
<span class="tooltip-title outfit-font">Installa su iPhone</span>
|
||||||
|
<ion-button fill="clear" size="small" class="dismiss-btn" (click)="dismissIosTooltip($event)">
|
||||||
|
<ion-icon name="close-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-body">
|
||||||
|
<p class="tooltip-text outfit-font">
|
||||||
|
Tocca il pulsante Condividi <span class="safari-icon-inline"><ion-icon name="share-outline"></ion-icon></span> in basso e seleziona <strong>"Aggiungi a schermata Home"</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-arrow"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -953,3 +953,323 @@ ion-title {
|
|||||||
color: #c0392b !important;
|
color: #c0392b !important;
|
||||||
border-color: #c0392b !important;
|
border-color: #c0392b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
PWA INSTALLATION PROMOTIONS (Android & iOS)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* 1. Android Installation Banner */
|
||||||
|
.pwa-android-banner {
|
||||||
|
--background: rgba(255, 255, 255, 0.04);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-left: 4px solid var(--ion-color-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
animation: slideInBanner 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(var(--ion-color-secondary-rgb), 0.12);
|
||||||
|
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-subtitle {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.install-action-btn {
|
||||||
|
--border-radius: 10px;
|
||||||
|
--box-shadow: 0 4px 10px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-action-btn {
|
||||||
|
--color: rgba(255, 255, 255, 0.5);
|
||||||
|
--padding-start: 4px;
|
||||||
|
--padding-end: 4px;
|
||||||
|
margin: 0;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. iOS Safari Contextual Tooltip */
|
||||||
|
.pwa-ios-tooltip-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10000;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 385px;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 12px 36px rgba(0, 0, 0, 0.45));
|
||||||
|
transition: bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.has-player {
|
||||||
|
bottom: 84px; /* Float above active bottom player footer */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-ios-tooltip {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(26, 26, 46, 0.96) !important;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
-webkit-backdrop-filter: blur(25px);
|
||||||
|
border: 1.5px solid rgba(var(--ion-color-secondary-rgb), 0.25);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.02);
|
||||||
|
animation: floatTooltip 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275), bounceGently 3s ease-in-out infinite;
|
||||||
|
|
||||||
|
.tooltip-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-btn {
|
||||||
|
--color: rgba(255, 255, 255, 0.45);
|
||||||
|
--padding-start: 4px;
|
||||||
|
--padding-end: 4px;
|
||||||
|
margin: 0;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-body {
|
||||||
|
.tooltip-text {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safari-icon-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #007aff; /* Apple System Blue */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 12px solid transparent;
|
||||||
|
border-right: 12px solid transparent;
|
||||||
|
border-top: 12px solid rgba(26, 26, 46, 0.96);
|
||||||
|
position: absolute;
|
||||||
|
bottom: -11px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 12px solid transparent;
|
||||||
|
border-right: 12px solid transparent;
|
||||||
|
border-top: 12px solid rgba(var(--ion-color-secondary-rgb), 0.25);
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: -12px;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideInBanner {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatTooltip {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounceGently {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High Contrast mode adaptations */
|
||||||
|
:host-context(body.high-contrast) {
|
||||||
|
.pwa-android-banner {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border: 2px solid #000000 !important;
|
||||||
|
border-left-width: 6px !important;
|
||||||
|
|
||||||
|
.banner-title, .banner-subtitle {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
.banner-icon {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
.banner-icon-wrapper {
|
||||||
|
background: rgba(0, 0, 0, 0.05) !important;
|
||||||
|
border-color: #000000 !important;
|
||||||
|
}
|
||||||
|
.install-action-btn {
|
||||||
|
--background: #000000 !important;
|
||||||
|
--color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.dismiss-action-btn {
|
||||||
|
--color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-ios-tooltip {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border: 2px solid #000000 !important;
|
||||||
|
|
||||||
|
.tooltip-title, .tooltip-text, .tooltip-text strong {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
.dismiss-btn {
|
||||||
|
--color: #000000 !important;
|
||||||
|
}
|
||||||
|
.tooltip-arrow {
|
||||||
|
border-top-color: #ffffff !important;
|
||||||
|
&::after {
|
||||||
|
border-top-color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safari-icon-inline {
|
||||||
|
background: rgba(0, 0, 0, 0.05) !important;
|
||||||
|
border-color: #000000 !important;
|
||||||
|
ion-icon {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MyCantiService } from '../services/my-canti.service';
|
|||||||
import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component';
|
import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component';
|
||||||
import { CantiLettureService } from '../services/canti-letture.service';
|
import { CantiLettureService } from '../services/canti-letture.service';
|
||||||
import { ComunitaService } from '../services/comunita.service';
|
import { ComunitaService } from '../services/comunita.service';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
@@ -33,10 +34,14 @@ export class HomePage implements OnDestroy {
|
|||||||
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null);
|
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null);
|
||||||
public loadedThumbs = new Set<string>();
|
public loadedThumbs = new Set<string>();
|
||||||
public version = VERSION;
|
public version = VERSION;
|
||||||
|
public appName = environment.appName;
|
||||||
public isAddingSongs = signal<boolean>(false);
|
public isAddingSongs = signal<boolean>(false);
|
||||||
public reorderList = signal<any[]>([]);
|
public reorderList = signal<any[]>([]);
|
||||||
public limit = signal<number>(30);
|
public limit = signal<number>(30);
|
||||||
|
|
||||||
|
public showAndroidBanner = signal<boolean>(false);
|
||||||
|
public showIosTooltip = signal<boolean>(false);
|
||||||
|
|
||||||
public fontSize = signal<number>(1.0);
|
public fontSize = signal<number>(1.0);
|
||||||
public youtubePlayerService = inject(YoutubePlayerService);
|
public youtubePlayerService = inject(YoutubePlayerService);
|
||||||
public comunitaService = inject(ComunitaService);
|
public comunitaService = inject(ComunitaService);
|
||||||
@@ -110,23 +115,28 @@ export class HomePage implements OnDestroy {
|
|||||||
const onlyMine = this.showOnlyMine();
|
const onlyMine = this.showOnlyMine();
|
||||||
const topTen = this.showTopTen();
|
const topTen = this.showTopTen();
|
||||||
const suggeriti = this.showSuggeriti();
|
const suggeriti = this.showSuggeriti();
|
||||||
|
const activeIds = this.playlistService.activeListIds();
|
||||||
|
const selectionMode = this.playlistService.selectionMode();
|
||||||
|
|
||||||
let list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
|
// 1. Get base list depending on community filter
|
||||||
|
let list: any[] = [];
|
||||||
// Filter by Community if code is set and filter is active
|
|
||||||
const comunitaCode = this.comunitaService.comunitaCode();
|
const comunitaCode = this.comunitaService.comunitaCode();
|
||||||
const comunitaIds = this.comunitaService.comunitaCantiIds();
|
const comunitaIds = this.comunitaService.comunitaCantiIds();
|
||||||
if (comunitaCode && this.comunitaService.isFilterActive()) {
|
|
||||||
// Include parish custom/unvalidated canti
|
|
||||||
list = [...list, ...this.comunitaService.comunitaCantiPersonali()];
|
|
||||||
|
|
||||||
|
if (comunitaCode && this.comunitaService.isFilterActive()) {
|
||||||
|
// Base is standard canti + my canti + community custom canti
|
||||||
|
list = [
|
||||||
|
...this.cantiService.canti(),
|
||||||
|
...this.myCantiService.myCanti(),
|
||||||
|
...this.comunitaService.comunitaCantiPersonali()
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter to only community canti
|
||||||
list = list.filter(c => {
|
list = list.filter(c => {
|
||||||
return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id);
|
return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deduplicate: if a song exists in both validated and unvalidated lists,
|
// Deduplicate: parish-customized versions take precedence
|
||||||
// the NON-VALIDATED (parish-customized) version always takes precedence,
|
|
||||||
// so parishes can always personalize their songs even if never validated.
|
|
||||||
const seen = new Map<number | string, any>();
|
const seen = new Map<number | string, any>();
|
||||||
for (const canto of list) {
|
for (const canto of list) {
|
||||||
const key = canto.id_canti || canto.id;
|
const key = canto.id_canti || canto.id;
|
||||||
@@ -134,8 +144,6 @@ export class HomePage implements OnDestroy {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
seen.set(key, canto);
|
seen.set(key, canto);
|
||||||
} else {
|
} else {
|
||||||
// Precedence to non-validated (parish custom) version:
|
|
||||||
// overwrite only if current entry is validated and new one is non-validated
|
|
||||||
if (!existing.nonValidato && canto.nonValidato) {
|
if (!existing.nonValidato && canto.nonValidato) {
|
||||||
seen.set(key, canto);
|
seen.set(key, canto);
|
||||||
}
|
}
|
||||||
@@ -143,7 +151,7 @@ export class HomePage implements OnDestroy {
|
|||||||
}
|
}
|
||||||
list = Array.from(seen.values());
|
list = Array.from(seen.values());
|
||||||
|
|
||||||
// Sort list by community song number (num_canto) ASC!
|
// Sort by community song number (num_canto)
|
||||||
const cantiInfo = this.comunitaService.comunitaCantiInfo();
|
const cantiInfo = this.comunitaService.comunitaCantiInfo();
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const infoA = cantiInfo.find(x => x.id_canti === a.id_canti || x.id_canti === Number(a.id));
|
const infoA = cantiInfo.find(x => x.id_canti === a.id_canti || x.id_canti === Number(a.id));
|
||||||
@@ -152,49 +160,77 @@ export class HomePage implements OnDestroy {
|
|||||||
const numB = infoB ? Number(infoB.num_canto) : 999999;
|
const numB = infoB ? Number(infoB.num_canto) : 999999;
|
||||||
return numA - numB;
|
return numA - numB;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// General context: only standard canti + my canti
|
||||||
|
list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Cumulative filtering
|
||||||
|
|
||||||
|
// Filter by selectionMode / reorderList if in selection mode and not adding songs
|
||||||
|
if (selectionMode && !this.isAddingSongs()) {
|
||||||
|
return this.reorderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A. Filter by Playlist (if activeIds is present)
|
||||||
|
if (activeIds.length > 0 && !selectionMode) {
|
||||||
|
list = activeIds
|
||||||
|
.map(id => list.find(c => c.id === id))
|
||||||
|
.filter((c): c is any => !!c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. Filter by "Only Mine" (Miei)
|
||||||
if (onlyMine) {
|
if (onlyMine) {
|
||||||
list = this.myCantiService.myCanti();
|
const myIds = new Set(this.myCantiService.myCanti().map(c => c.id));
|
||||||
|
list = list.filter(c => myIds.has(c.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C. Filter by Liturgical Moment
|
||||||
if (litId !== null) {
|
if (litId !== null) {
|
||||||
list = list.filter(c => c.id_momenti?.includes(litId));
|
list = list.filter(c => c.id_momenti?.includes(litId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D. Filter by Thematic Moment
|
||||||
if (temId !== null) {
|
if (temId !== null) {
|
||||||
list = list.filter(c => c.id_momenti?.includes(temId));
|
list = list.filter(c => c.id_momenti?.includes(temId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E. Filter by Suggested (Suggeriti)
|
||||||
if (suggeriti) {
|
if (suggeriti) {
|
||||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||||
list = list.filter(c => suggMap.has(c.id_canti));
|
list = list.filter(c => suggMap.has(c.id_canti));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special List handling (from QR Code or Saved Playlists)
|
// F. Filter by Search Query
|
||||||
const activeIds = this.playlistService.activeListIds();
|
if (query) {
|
||||||
const selectionMode = this.playlistService.selectionMode();
|
const processedQuery = query.replace(/\buno\b/g, '1');
|
||||||
|
const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
|
||||||
|
|
||||||
// If we are in selection mode, only show restricted list if NOT adding songs
|
if (numberMatchPattern) {
|
||||||
if (selectionMode && !this.isAddingSongs()) {
|
const targetId = numberMatchPattern[1];
|
||||||
return this.reorderList();
|
list = list.filter(c => {
|
||||||
}
|
const commNum = this.getCommunitySongNumber(c);
|
||||||
|
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
||||||
// If we have an active playlist and NOT in selection mode, show ONLY those songs
|
|
||||||
if (activeIds.length > 0 && !selectionMode) {
|
|
||||||
// Filter only songs in the special list and KEEP THE ORDER
|
|
||||||
return activeIds
|
|
||||||
.map(id => list.find(c => c.id === id))
|
|
||||||
.filter((c): c is any => !!c);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (suggeriti) {
|
|
||||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
|
||||||
list = list.sort((a, b) => {
|
|
||||||
const pesoA = suggMap.get(a.id_canti) || 0;
|
|
||||||
const pesoB = suggMap.get(b.id_canti) || 0;
|
|
||||||
return pesoB - pesoA;
|
|
||||||
});
|
});
|
||||||
} else if (topTen) {
|
} else {
|
||||||
|
list = list.filter(c => {
|
||||||
|
const titleMatch = this.normalize(c.titolo).includes(query);
|
||||||
|
const authorMatch = this.normalize(c.autore).includes(query);
|
||||||
|
const commNum = this.getCommunitySongNumber(c);
|
||||||
|
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
|
||||||
|
|
||||||
|
const lyricsPlain = (c.testo || '')
|
||||||
|
.replace(/{.*?}/g, '')
|
||||||
|
.replace(/\n/g, ' ');
|
||||||
|
const lyricsMatch = this.normalize(lyricsPlain).includes(query);
|
||||||
|
|
||||||
|
return titleMatch || authorMatch || lyricsMatch || numberMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// G. Sorting/Ordering
|
||||||
|
if (topTen) {
|
||||||
const eseguiti = this.cantiService.cantiEseguiti();
|
const eseguiti = this.cantiService.cantiEseguiti();
|
||||||
const eseguitiMap = new Map<number, number>();
|
const eseguitiMap = new Map<number, number>();
|
||||||
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
||||||
@@ -204,37 +240,16 @@ export class HomePage implements OnDestroy {
|
|||||||
const numB = eseguitiMap.get(b.id_canti) || 0;
|
const numB = eseguitiMap.get(b.id_canti) || 0;
|
||||||
return numB - numA;
|
return numB - numA;
|
||||||
});
|
});
|
||||||
}
|
} else if (suggeriti) {
|
||||||
|
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||||
if (!query) return list;
|
list = list.sort((a, b) => {
|
||||||
|
const pesoA = suggMap.get(a.id_canti) || 0;
|
||||||
// Converte "uno" in "1" per facilitare la ricerca vocale (es. "numero uno")
|
const pesoB = suggMap.get(b.id_canti) || 0;
|
||||||
const processedQuery = query.replace(/\buno\b/g, '1');
|
return pesoB - pesoA;
|
||||||
|
|
||||||
// Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti/comunita number
|
|
||||||
const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
|
|
||||||
if (numberMatchPattern) {
|
|
||||||
const targetId = numberMatchPattern[1];
|
|
||||||
return list.filter(c => {
|
|
||||||
const commNum = this.getCommunitySongNumber(c);
|
|
||||||
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.filter(c => {
|
return list;
|
||||||
const titleMatch = this.normalize(c.titolo).includes(query);
|
|
||||||
const authorMatch = this.normalize(c.autore).includes(query);
|
|
||||||
const commNum = this.getCommunitySongNumber(c);
|
|
||||||
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
|
|
||||||
|
|
||||||
// Strip tags like {Chorus} and newlines from lyrics before searching
|
|
||||||
const lyricsPlain = (c.testo || '')
|
|
||||||
.replace(/{.*?}/g, '')
|
|
||||||
.replace(/\n/g, ' ');
|
|
||||||
const lyricsMatch = this.normalize(lyricsPlain).includes(query);
|
|
||||||
|
|
||||||
return titleMatch || authorMatch || lyricsMatch || numberMatch;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
public visibleCanti = computed(() => {
|
public visibleCanti = computed(() => {
|
||||||
@@ -242,6 +257,49 @@ export class HomePage implements OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Check if install prompts should be visible
|
||||||
|
const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true';
|
||||||
|
const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true';
|
||||||
|
|
||||||
|
this.showAndroidBanner.set(
|
||||||
|
this.settingsService.isAndroid() &&
|
||||||
|
!this.settingsService.isStandalone() &&
|
||||||
|
!androidDismissed
|
||||||
|
);
|
||||||
|
|
||||||
|
this.showIosTooltip.set(
|
||||||
|
this.settingsService.isIos() &&
|
||||||
|
!this.settingsService.isStandalone() &&
|
||||||
|
!iosDismissed
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track initial community filter state to avoid clearing during the initial run of the effect
|
||||||
|
let prevFilterActive = this.comunitaService.isFilterActive();
|
||||||
|
let prevComunitaCode = this.comunitaService.comunitaCode();
|
||||||
|
|
||||||
|
// Automatically reset all other filters and active playlist when toggling the community filter or changing community
|
||||||
|
effect(() => {
|
||||||
|
const active = this.comunitaService.isFilterActive();
|
||||||
|
const code = this.comunitaService.comunitaCode();
|
||||||
|
|
||||||
|
if (active !== prevFilterActive || code !== prevComunitaCode) {
|
||||||
|
prevFilterActive = active;
|
||||||
|
prevComunitaCode = code;
|
||||||
|
|
||||||
|
this.selectedLiturgico.set(null);
|
||||||
|
this.selectedTematico.set(null);
|
||||||
|
this.showOnlyMine.set(false);
|
||||||
|
this.showTopTen.set(false);
|
||||||
|
this.showSuggeriti.set(false);
|
||||||
|
this.playlistService.activeListIds.set([]);
|
||||||
|
this.playlistService.activeListName.set(null);
|
||||||
|
this.playlistService.activePlaylistId.set(null);
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.activeFilterType.set(null);
|
||||||
|
this.limit.set(10);
|
||||||
|
}
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
// Sync speech recognition results to search query
|
// Sync speech recognition results to search query
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const transcript = this.audioEngine.searchTranscript();
|
const transcript = this.audioEngine.searchTranscript();
|
||||||
@@ -307,6 +365,34 @@ export class HomePage implements OnDestroy {
|
|||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dismissAndroidBanner(event?: Event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
|
this.showAndroidBanner.set(false);
|
||||||
|
localStorage.setItem('pwa-android-dismissed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissIosTooltip(event?: Event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
|
this.showIosTooltip.set(false);
|
||||||
|
localStorage.setItem('pwa-ios-dismissed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
async installAndroidPwa() {
|
||||||
|
if (this.settingsService.deferredPrompt()) {
|
||||||
|
await this.settingsService.installPwa();
|
||||||
|
this.dismissAndroidBanner();
|
||||||
|
} else {
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'Tocca il menu del browser (i tre puntini in alto a destra) e seleziona "Aggiungi a schermata Home" o "Installa app".',
|
||||||
|
duration: 6000,
|
||||||
|
position: 'bottom',
|
||||||
|
color: 'secondary',
|
||||||
|
buttons: [{ text: 'OK', role: 'cancel' }]
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleImport(base64: string) {
|
handleImport(base64: string) {
|
||||||
try {
|
try {
|
||||||
const decoded = decodeURIComponent(escape(atob(base64)));
|
const decoded = decodeURIComponent(escape(atob(base64)));
|
||||||
@@ -382,10 +468,6 @@ export class HomePage implements OnDestroy {
|
|||||||
|
|
||||||
toggleOnlyMine() {
|
toggleOnlyMine() {
|
||||||
this.showOnlyMine.update(v => !v);
|
this.showOnlyMine.update(v => !v);
|
||||||
if (this.showOnlyMine()) {
|
|
||||||
this.selectedLiturgico.set(null);
|
|
||||||
this.selectedTematico.set(null);
|
|
||||||
}
|
|
||||||
this.limit.set(10);
|
this.limit.set(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +483,6 @@ export class HomePage implements OnDestroy {
|
|||||||
} else if (type === 'tematico') {
|
} else if (type === 'tematico') {
|
||||||
this.selectedTematico.update(cur => cur === id ? null : id);
|
this.selectedTematico.update(cur => cur === id ? null : id);
|
||||||
}
|
}
|
||||||
this.showOnlyMine.set(false);
|
|
||||||
this.activeFilterType.set(null);
|
this.activeFilterType.set(null);
|
||||||
this.limit.set(10);
|
this.limit.set(10);
|
||||||
}
|
}
|
||||||
@@ -510,10 +591,6 @@ export class HomePage implements OnDestroy {
|
|||||||
this.playlistService.activePlaylistId.set(pl.id);
|
this.playlistService.activePlaylistId.set(pl.id);
|
||||||
this.activeFilterType.set(null);
|
this.activeFilterType.set(null);
|
||||||
this.limit.set(50);
|
this.limit.set(50);
|
||||||
// Clear other filters
|
|
||||||
this.selectedLiturgico.set(null);
|
|
||||||
this.selectedTematico.set(null);
|
|
||||||
this.showOnlyMine.set(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTopTen() {
|
toggleTopTen() {
|
||||||
@@ -580,7 +657,7 @@ export class HomePage implements OnDestroy {
|
|||||||
const numId = parseInt(id, 10);
|
const numId = parseInt(id, 10);
|
||||||
if (isNaN(numId)) return null;
|
if (isNaN(numId)) return null;
|
||||||
const es = this.cantiService.cantiEseguiti().find(x => x.id_canti === numId);
|
const es = this.cantiService.cantiEseguiti().find(x => x.id_canti === numId);
|
||||||
return es ? es.num : null;
|
return es ? es.num : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
onImgLoad(id: string) {
|
onImgLoad(id: string) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
|
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
|
||||||
<ion-label class="outfit-font">
|
<ion-label class="outfit-font">
|
||||||
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold;">Installa Applicazione</h2>
|
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold;">Installa Applicazione</h2>
|
||||||
<p class="settings-item-subtitle">Aggiungi CantiCristiani alla schermata home</p>
|
<p class="settings-item-subtitle">Aggiungi {{ appName }} alla schermata home</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-button fill="solid" size="small" color="secondary" slot="end" class="outfit-font" style="--border-radius: 12px; font-weight: bold; text-transform: none; margin: 0;">
|
<ion-button fill="solid" size="small" color="secondary" slot="end" class="outfit-font" style="--border-radius: 12px; font-weight: bold; text-transform: none; margin: 0;">
|
||||||
Installa
|
Installa
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class SettingsPage {
|
|||||||
|
|
||||||
public version = VERSION;
|
public version = VERSION;
|
||||||
public contactEmail = environment.contactEmail;
|
public contactEmail = environment.contactEmail;
|
||||||
|
public appName = environment.appName;
|
||||||
public showIosInstructions = false;
|
public showIosInstructions = false;
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Storage } from '@ionic/storage-angular';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
id_canto: number;
|
id_canto?: number;
|
||||||
peso: number;
|
id_canti?: number;
|
||||||
|
peso?: number;
|
||||||
|
score?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MassDetails {
|
export interface MassDetails {
|
||||||
@@ -62,9 +64,13 @@ export class CantiLettureService {
|
|||||||
if (Array.isArray(mass.suggestions)) {
|
if (Array.isArray(mass.suggestions)) {
|
||||||
// Legacy flat suggestions format
|
// Legacy flat suggestions format
|
||||||
mass.suggestions.forEach(s => {
|
mass.suggestions.forEach(s => {
|
||||||
if (s && s.id_canto) {
|
if (s) {
|
||||||
newMap.set(s.id_canto, s.peso);
|
const id = s.id_canto || (s as any).id_canti;
|
||||||
newMomentsMap.set(s.id_canto, ['Generale']);
|
const weight = s.peso !== undefined ? s.peso : (s as any).score;
|
||||||
|
if (id) {
|
||||||
|
newMap.set(id, weight);
|
||||||
|
newMomentsMap.set(id, ['Generale']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -73,17 +79,21 @@ export class CantiLettureService {
|
|||||||
const momentSuggestions = (mass.suggestions as any)[moment];
|
const momentSuggestions = (mass.suggestions as any)[moment];
|
||||||
if (Array.isArray(momentSuggestions)) {
|
if (Array.isArray(momentSuggestions)) {
|
||||||
momentSuggestions.forEach(s => {
|
momentSuggestions.forEach(s => {
|
||||||
if (s && s.id_canto) {
|
if (s) {
|
||||||
|
const id = s.id_canto || (s as any).id_canti;
|
||||||
|
const weight = s.peso !== undefined ? s.peso : (s as any).score;
|
||||||
|
if (id) {
|
||||||
// Save max weight
|
// Save max weight
|
||||||
const existingWeight = newMap.get(s.id_canto) || 0;
|
const existingWeight = newMap.get(id) || 0;
|
||||||
if (s.peso > existingWeight) {
|
if (weight > existingWeight) {
|
||||||
newMap.set(s.id_canto, s.peso);
|
newMap.set(id, weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save liturgical moment
|
// Save liturgical moment
|
||||||
const existingMoments = newMomentsMap.get(s.id_canto) || [];
|
const existingMoments = newMomentsMap.get(id) || [];
|
||||||
if (!existingMoments.includes(moment)) {
|
if (!existingMoments.includes(moment)) {
|
||||||
newMomentsMap.set(s.id_canto, [...existingMoments, moment]);
|
newMomentsMap.set(id, [...existingMoments, moment]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -129,8 +139,12 @@ export class CantiLettureService {
|
|||||||
let fetchedData: CantiLettureData | null = null;
|
let fetchedData: CantiLettureData | null = null;
|
||||||
|
|
||||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||||
const primaryUrl = isProduction ? `${window.location.origin}${this.SECURE_JSON_URL}` : this.JSON_URL;
|
const primaryUrl = isProduction
|
||||||
const fallbackUrl = isProduction ? this.JSON_URL : `${window.location.origin}${this.SECURE_JSON_URL}`;
|
? `${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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
console.log('Fetching mass data from primary URL:', primaryUrl);
|
||||||
@@ -182,7 +196,11 @@ export class CantiLettureService {
|
|||||||
|
|
||||||
// Always select today's mass if available on load, else find closest future date
|
// Always select today's mass if available on load, else find closest future date
|
||||||
if (massesList.length > 0) {
|
if (massesList.length > 0) {
|
||||||
const todayStr = new Date().toISOString().split('T')[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);
|
const match = massesList.find(m => m.date === todayStr);
|
||||||
if (match) {
|
if (match) {
|
||||||
this.selectedMassDate.set(match.date);
|
this.selectedMassDate.set(match.date);
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { MyCantiService } from './my-canti.service';
|
||||||
|
import { Storage } from '@ionic/storage-angular';
|
||||||
|
import { CantiService } from './canti.service';
|
||||||
|
import { ToastController } from '@ionic/angular';
|
||||||
|
|
||||||
import { MyCanti } from './my-canti';
|
describe('MyCantiService', () => {
|
||||||
|
let service: MyCantiService;
|
||||||
describe('MyCanti', () => {
|
let mockStorage: any;
|
||||||
let service: MyCanti;
|
let mockCantiService: any;
|
||||||
|
let mockToastCtrl: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({});
|
mockStorage = jasmine.createSpyObj('Storage', ['create', 'get', 'set']);
|
||||||
service = TestBed.inject(MyCanti);
|
mockStorage.get.and.returnValue(Promise.resolve([]));
|
||||||
|
mockStorage.set.and.returnValue(Promise.resolve());
|
||||||
|
|
||||||
|
mockCantiService = jasmine.createSpyObj('CantiService', ['getStorage']);
|
||||||
|
mockCantiService.getStorage.and.returnValue(mockStorage);
|
||||||
|
|
||||||
|
mockToastCtrl = jasmine.createSpyObj('ToastController', ['create']);
|
||||||
|
mockToastCtrl.create.and.returnValue(Promise.resolve({
|
||||||
|
present: jasmine.createSpy('present').and.returnValue(Promise.resolve())
|
||||||
|
}));
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
MyCantiService,
|
||||||
|
{ provide: Storage, useValue: mockStorage },
|
||||||
|
{ provide: CantiService, useValue: mockCantiService },
|
||||||
|
{ provide: ToastController, useValue: mockToastCtrl }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
service = TestBed.inject(MyCantiService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { PlaylistService } from './playlist.service';
|
||||||
|
import { Storage } from '@ionic/storage-angular';
|
||||||
|
import { CantiService } from './canti.service';
|
||||||
|
import { ComunitaService } from './comunita.service';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
|
||||||
|
describe('PlaylistService', () => {
|
||||||
|
let service: PlaylistService;
|
||||||
|
let mockStorage: any;
|
||||||
|
let mockCantiService: any;
|
||||||
|
let mockComunitaService: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockStorage = jasmine.createSpyObj('Storage', ['create', 'get', 'set']);
|
||||||
|
mockStorage.create.and.returnValue(Promise.resolve(mockStorage));
|
||||||
|
mockStorage.get.and.callFake((key: string) => {
|
||||||
|
if (key === 'playlists') {
|
||||||
|
return Promise.resolve([{ id: '1', name: 'Playlist Generale', ids: ['101', '102'] }]);
|
||||||
|
}
|
||||||
|
if (key === 'playlists_comunita_123456') {
|
||||||
|
return Promise.resolve([{ id: '2', name: 'Playlist Comunita 123', ids: ['103'] }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
mockStorage.set.and.returnValue(Promise.resolve());
|
||||||
|
|
||||||
|
mockCantiService = jasmine.createSpyObj('CantiService', ['canti']);
|
||||||
|
mockCantiService.canti.and.returnValue([]);
|
||||||
|
|
||||||
|
mockComunitaService = {
|
||||||
|
comunitaCode: signal(''),
|
||||||
|
isFilterActive: signal(false),
|
||||||
|
comunitaScalette: signal([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
PlaylistService,
|
||||||
|
{ provide: Storage, useValue: mockStorage },
|
||||||
|
{ provide: CantiService, useValue: mockCantiService },
|
||||||
|
{ provide: ComunitaService, useValue: mockComunitaService }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(PlaylistService);
|
||||||
|
// Simulate init() completion
|
||||||
|
await service.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load general playlists when community is not active', async () => {
|
||||||
|
mockComunitaService.isFilterActive.set(false);
|
||||||
|
mockComunitaService.comunitaCode.set('');
|
||||||
|
|
||||||
|
await service.loadPlaylistsForCurrentContext();
|
||||||
|
|
||||||
|
expect(service.playlists().length).toBe(1);
|
||||||
|
expect(service.playlists()[0].name).toBe('Playlist Generale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load community-specific playlists when community is active', async () => {
|
||||||
|
mockComunitaService.comunitaCode.set('123456');
|
||||||
|
mockComunitaService.isFilterActive.set(true);
|
||||||
|
|
||||||
|
await service.loadPlaylistsForCurrentContext();
|
||||||
|
|
||||||
|
expect(service.playlists().length).toBe(1);
|
||||||
|
expect(service.playlists()[0].name).toBe('Playlist Comunita 123');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, signal, inject, computed } from '@angular/core';
|
import { Injectable, signal, inject, computed, effect } from '@angular/core';
|
||||||
import { Storage } from '@ionic/storage-angular';
|
import { Storage } from '@ionic/storage-angular';
|
||||||
import { Canto, CantiService } from './canti.service';
|
import { Canto, CantiService } from './canti.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
@@ -50,19 +50,42 @@ export class PlaylistService {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
|
|
||||||
|
// Watch for context changes to dynamically reload the correct playlists
|
||||||
|
effect(() => {
|
||||||
|
// Trigger effect on changes to these signals
|
||||||
|
this.comunitaService.comunitaCode();
|
||||||
|
this.comunitaService.isFilterActive();
|
||||||
|
|
||||||
|
// Load the playlists
|
||||||
|
this.loadPlaylistsForCurrentContext();
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPlaylistsStorageKey(): string {
|
||||||
|
const code = this.comunitaService.comunitaCode();
|
||||||
|
const isCommunityActive = this.comunitaService.isFilterActive();
|
||||||
|
if (code && isCommunityActive) {
|
||||||
|
return `playlists_comunita_${code}`;
|
||||||
|
}
|
||||||
|
return 'playlists';
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const storage = await this.storage.create();
|
const storage = await this.storage.create();
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
const saved = await this._storage.get('playlists');
|
await this.loadPlaylistsForCurrentContext();
|
||||||
if (saved) {
|
|
||||||
this.playlists.set(saved);
|
|
||||||
}
|
|
||||||
const last = await this._storage?.get('lastPlaylist');
|
|
||||||
if (last) {
|
|
||||||
this.lastPlaylist.set(last);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadPlaylistsForCurrentContext() {
|
||||||
|
if (!this._storage) return;
|
||||||
|
const key = this.getPlaylistsStorageKey();
|
||||||
|
const saved = await this._storage.get(key);
|
||||||
|
this.playlists.set(saved || []);
|
||||||
|
|
||||||
|
const lastKey = `lastPlaylist_${key}`;
|
||||||
|
const last = await this._storage.get(lastKey);
|
||||||
|
this.lastPlaylist.set(last || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelectionMode() {
|
toggleSelectionMode() {
|
||||||
@@ -92,6 +115,8 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async savePlaylist(name: string, ids: string[]) {
|
async savePlaylist(name: string, ids: string[]) {
|
||||||
|
const key = this.getPlaylistsStorageKey();
|
||||||
|
const lastKey = `lastPlaylist_${key}`;
|
||||||
const editId = this.activePlaylistId();
|
const editId = this.activePlaylistId();
|
||||||
let newPlaylist: any;
|
let newPlaylist: any;
|
||||||
|
|
||||||
@@ -116,8 +141,8 @@ export class PlaylistService {
|
|||||||
this.lastPlaylist.set(newPlaylist);
|
this.lastPlaylist.set(newPlaylist);
|
||||||
this.activeListIds.set(ids);
|
this.activeListIds.set(ids);
|
||||||
this.activeListName.set(name);
|
this.activeListName.set(name);
|
||||||
await this._storage?.set('playlists', this.playlists());
|
await this._storage?.set(key, this.playlists());
|
||||||
await this._storage?.set('lastPlaylist', newPlaylist);
|
await this._storage?.set(lastKey, newPlaylist);
|
||||||
|
|
||||||
// Reset selection mode, IDs and editing state after saving
|
// Reset selection mode, IDs and editing state after saving
|
||||||
this.selectedIds.set(new Set());
|
this.selectedIds.set(new Set());
|
||||||
@@ -126,8 +151,9 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deletePlaylist(id: string) {
|
async deletePlaylist(id: string) {
|
||||||
|
const key = this.getPlaylistsStorageKey();
|
||||||
this.playlists.update(p => p.filter(pl => pl.id !== id));
|
this.playlists.update(p => p.filter(pl => pl.id !== id));
|
||||||
await this._storage?.set('playlists', this.playlists());
|
await this._storage?.set(key, this.playlists());
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateQR(ids: string[], name: string): Promise<string> {
|
async generateQR(ids: string[], name: string): Promise<string> {
|
||||||
@@ -148,7 +174,7 @@ export class PlaylistService {
|
|||||||
const base64 = btoa(unescape(encodeURIComponent(data)));
|
const base64 = btoa(unescape(encodeURIComponent(data)));
|
||||||
|
|
||||||
// Always use the production URL for sharing links as requested
|
// Always use the production URL for sharing links as requested
|
||||||
const productionUrl = 'https://www.canticristiani.it/ionic';
|
const productionUrl = 'https://www.canticristiani.it';
|
||||||
return `${productionUrl}/?import=${base64}`;
|
return `${productionUrl}/?import=${base64}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { Injectable, signal, effect } from '@angular/core';
|
|||||||
})
|
})
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
/** Default mode for viewing songs: true = chords, false = text only */
|
/** Default mode for viewing songs: true = chords, false = text only */
|
||||||
public showChordsDefault = signal<boolean>(false);
|
public showChordsDefault = signal<boolean>(true);
|
||||||
|
|
||||||
/** UI Fullscreen mode (lyrics only): true = active */
|
/** UI Fullscreen mode (lyrics only): true = active */
|
||||||
public fullscreenMode = signal<boolean>(false);
|
public fullscreenMode = signal<boolean>(false);
|
||||||
|
|
||||||
/** Browser Fullscreen state: true = fullscreen active */
|
/** Browser Fullscreen state: true = fullscreen active */
|
||||||
public browserFullscreen = signal<boolean>(
|
public browserFullscreen = signal<boolean>(
|
||||||
!!(
|
typeof document !== 'undefined' && !!(
|
||||||
document.fullscreenElement ||
|
document.fullscreenElement ||
|
||||||
(document as any).webkitFullscreenElement ||
|
(document as any).webkitFullscreenElement ||
|
||||||
(document as any).mozFullScreenElement ||
|
(document as any).mozFullScreenElement ||
|
||||||
@@ -27,25 +27,25 @@ export class SettingsService {
|
|||||||
public showEditor = signal<boolean>(false);
|
public showEditor = signal<boolean>(false);
|
||||||
|
|
||||||
/** Schermo sempre acceso: true = attiva Screen Wake Lock */
|
/** Schermo sempre acceso: true = attiva Screen Wake Lock */
|
||||||
public keepScreenOn = signal<boolean>(false);
|
public keepScreenOn = signal<boolean>(true);
|
||||||
|
|
||||||
/** Funzionalità Comunità: true = il chip comunità è visibile nella home */
|
/** Funzionalità Comunità: true = il chip comunità è visibile nella home */
|
||||||
public comunitaEnabled = signal<boolean>(true);
|
public comunitaEnabled = signal<boolean>(false);
|
||||||
|
|
||||||
/** Invio dati statistici: true = invia pacchetto dati statistici */
|
/** Invio dati statistici: true = invia pacchetto dati statistici */
|
||||||
public invioDatiStatistici = signal<boolean>(false);
|
public invioDatiStatistici = signal<boolean>(false);
|
||||||
|
|
||||||
/** Visualizza tag sotto autore nella lista canti: true = attivo */
|
/** Visualizza tag sotto autore nella lista canti: true = attivo */
|
||||||
public showTagsInList = signal<boolean>(false);
|
public showTagsInList = signal<boolean>(true);
|
||||||
|
|
||||||
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
||||||
public showUpdateDate = signal<boolean>(false);
|
public showUpdateDate = signal<boolean>(true);
|
||||||
|
|
||||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
||||||
public enableStandardAutoscroll = signal<boolean>(false);
|
public enableStandardAutoscroll = signal<boolean>(true);
|
||||||
|
|
||||||
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
|
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
|
||||||
public enableAcousticAutoscroll = signal<boolean>(true);
|
public enableAcousticAutoscroll = signal<boolean>(false);
|
||||||
|
|
||||||
private wakeLock: any = null;
|
private wakeLock: any = null;
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ export class SettingsService {
|
|||||||
public showInstallButton = signal<boolean>(false);
|
public showInstallButton = signal<boolean>(false);
|
||||||
public isStandalone = signal<boolean>(false);
|
public isStandalone = signal<boolean>(false);
|
||||||
public isIos = signal<boolean>(false);
|
public isIos = signal<boolean>(false);
|
||||||
|
public isAndroid = signal<boolean>(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Detect PWA status
|
// Detect PWA status
|
||||||
@@ -65,6 +66,9 @@ export class SettingsService {
|
|||||||
this.isIos.set(
|
this.isIos.set(
|
||||||
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
||||||
);
|
);
|
||||||
|
this.isAndroid.set(
|
||||||
|
/Android/i.test(navigator.userAgent)
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Browser environment does not support display-mode media query');
|
console.warn('Browser environment does not support display-mode media query');
|
||||||
}
|
}
|
||||||
@@ -84,59 +88,103 @@ export class SettingsService {
|
|||||||
this.isStandalone.set(true);
|
this.isStandalone.set(true);
|
||||||
console.log('PWA was installed');
|
console.log('PWA was installed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Migration: force settings defaults once for existing users to match the new rules
|
||||||
|
const migrationKey = 'defaults-migrated-20260521';
|
||||||
|
if (localStorage.getItem(migrationKey) !== 'true') {
|
||||||
|
localStorage.setItem('show-chords-default', 'true');
|
||||||
|
localStorage.setItem('fullscreen-mode', this.isIos().toString());
|
||||||
|
localStorage.setItem('show-editor', 'false');
|
||||||
|
localStorage.setItem('auto-advance', 'true');
|
||||||
|
localStorage.setItem('keep-screen-on', 'true');
|
||||||
|
localStorage.setItem('comunita-enabled', 'false');
|
||||||
|
localStorage.setItem('invio-dati-statistici', 'false');
|
||||||
|
localStorage.setItem('show-tags-in-list', 'true');
|
||||||
|
localStorage.setItem('show-update-date', 'true');
|
||||||
|
localStorage.setItem('enable-standard-autoscroll', 'true');
|
||||||
|
localStorage.setItem('enable-acoustic-autoscroll', 'false');
|
||||||
|
|
||||||
|
// ThemeService high contrast default
|
||||||
|
localStorage.setItem('high-contrast', 'true');
|
||||||
|
|
||||||
|
localStorage.setItem(migrationKey, 'true');
|
||||||
|
}
|
||||||
|
|
||||||
const savedChords = localStorage.getItem('show-chords-default');
|
const savedChords = localStorage.getItem('show-chords-default');
|
||||||
if (savedChords !== null) {
|
if (savedChords !== null) {
|
||||||
this.showChordsDefault.set(savedChords === 'true');
|
this.showChordsDefault.set(savedChords === 'true');
|
||||||
|
} else {
|
||||||
|
this.showChordsDefault.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedFullscreen = localStorage.getItem('fullscreen-mode');
|
const savedFullscreen = localStorage.getItem('fullscreen-mode');
|
||||||
if (savedFullscreen !== null) {
|
if (savedFullscreen !== null) {
|
||||||
this.fullscreenMode.set(savedFullscreen === 'true');
|
this.fullscreenMode.set(savedFullscreen === 'true');
|
||||||
|
} else {
|
||||||
|
this.fullscreenMode.set(this.isIos());
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedEditor = localStorage.getItem('show-editor');
|
const savedEditor = localStorage.getItem('show-editor');
|
||||||
if (savedEditor !== null) {
|
if (savedEditor !== null) {
|
||||||
this.showEditor.set(savedEditor === 'true');
|
this.showEditor.set(savedEditor === 'true');
|
||||||
|
} else {
|
||||||
|
this.showEditor.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedAutoAdvance = localStorage.getItem('auto-advance');
|
const savedAutoAdvance = localStorage.getItem('auto-advance');
|
||||||
if (savedAutoAdvance !== null) {
|
if (savedAutoAdvance !== null) {
|
||||||
this.autoAdvance.set(savedAutoAdvance === 'true');
|
this.autoAdvance.set(savedAutoAdvance === 'true');
|
||||||
|
} else {
|
||||||
|
this.autoAdvance.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
|
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
|
||||||
if (savedKeepScreenOn !== null) {
|
if (savedKeepScreenOn !== null) {
|
||||||
this.keepScreenOn.set(savedKeepScreenOn === 'true');
|
this.keepScreenOn.set(savedKeepScreenOn === 'true');
|
||||||
|
} else {
|
||||||
|
this.keepScreenOn.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
|
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
|
||||||
if (savedComunitaEnabled !== null) {
|
if (savedComunitaEnabled !== null) {
|
||||||
this.comunitaEnabled.set(savedComunitaEnabled === 'true');
|
this.comunitaEnabled.set(savedComunitaEnabled === 'true');
|
||||||
|
} else {
|
||||||
|
this.comunitaEnabled.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedStats = localStorage.getItem('invio-dati-statistici');
|
const savedStats = localStorage.getItem('invio-dati-statistici');
|
||||||
if (savedStats !== null) {
|
if (savedStats !== null) {
|
||||||
this.invioDatiStatistici.set(savedStats === 'true');
|
this.invioDatiStatistici.set(savedStats === 'true');
|
||||||
|
} else {
|
||||||
|
this.invioDatiStatistici.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedShowTags = localStorage.getItem('show-tags-in-list');
|
const savedShowTags = localStorage.getItem('show-tags-in-list');
|
||||||
if (savedShowTags !== null) {
|
if (savedShowTags !== null) {
|
||||||
this.showTagsInList.set(savedShowTags === 'true');
|
this.showTagsInList.set(savedShowTags === 'true');
|
||||||
|
} else {
|
||||||
|
this.showTagsInList.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedShowUpdateDate = localStorage.getItem('show-update-date');
|
const savedShowUpdateDate = localStorage.getItem('show-update-date');
|
||||||
if (savedShowUpdateDate !== null) {
|
if (savedShowUpdateDate !== null) {
|
||||||
this.showUpdateDate.set(savedShowUpdateDate === 'true');
|
this.showUpdateDate.set(savedShowUpdateDate === 'true');
|
||||||
|
} else {
|
||||||
|
this.showUpdateDate.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
||||||
if (savedStandardAutoscroll !== null) {
|
if (savedStandardAutoscroll !== null) {
|
||||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||||
|
} else {
|
||||||
|
this.enableStandardAutoscroll.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
|
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
|
||||||
if (savedAcousticAutoscroll !== null) {
|
if (savedAcousticAutoscroll !== null) {
|
||||||
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
|
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
|
||||||
|
} else {
|
||||||
|
this.enableAcousticAutoscroll.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||||
|
|||||||
@@ -4,25 +4,27 @@ import { Injectable, signal, effect } from '@angular/core';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ThemeService {
|
export class ThemeService {
|
||||||
public highContrast = signal<boolean>(false);
|
public highContrast = signal<boolean>(true);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Load from localStorage
|
// Load from localStorage
|
||||||
const saved = localStorage.getItem('high-contrast');
|
const saved = localStorage.getItem('high-contrast');
|
||||||
if (saved === 'true') {
|
if (saved !== null) {
|
||||||
this.highContrast.set(true);
|
this.highContrast.set(saved === 'true');
|
||||||
} else {
|
} else {
|
||||||
this.highContrast.set(false);
|
this.highContrast.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effect to apply class to body
|
// Effect to apply class to body
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const isHigh = this.highContrast();
|
const isHigh = this.highContrast();
|
||||||
|
if (typeof document !== 'undefined' && document.body) {
|
||||||
if (isHigh) {
|
if (isHigh) {
|
||||||
document.body.classList.add('high-contrast');
|
document.body.classList.add('high-contrast');
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove('high-contrast');
|
document.body.classList.remove('high-contrast');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
localStorage.setItem('high-contrast', isHigh.toString());
|
localStorage.setItem('high-contrast', isHigh.toString());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const VERSION = '2026.05.20.1624';
|
export const VERSION = '2026.05.22.0110';
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -1,4 +1,5 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
contactEmail: 'info@canticristiani.it'
|
contactEmail: 'info@canticristiani.it',
|
||||||
|
appName: 'CantiCristiani'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
contactEmail: 'info@canticristiani.it'
|
contactEmail: 'info@canticristiani.it',
|
||||||
|
appName: 'CantiCristiani'
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { enableProdMode } from '@angular/core';
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
|
if (environment.production) {
|
||||||
|
enableProdMode();
|
||||||
|
}
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||||
.catch(err => console.log(err));
|
.catch(err => console.log(err));
|
||||||
|
|||||||