fix: risoluzione importazione playlist tramite link di condivisione e fix installabilità PWA su Android

This commit is contained in:
David Frassi
2026-05-22 01:22:26 +02:00
parent d82d56a3cc
commit adc3d510ad
39 changed files with 842 additions and 299 deletions
-119
View File
@@ -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"
+2 -1
View File
@@ -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:"
+1
View File
@@ -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; }
-1
View File
@@ -7,7 +7,6 @@
"installMode": "prefetch", "installMode": "prefetch",
"resources": { "resources": {
"files": [ "files": [
"/favicon.ico",
"/index.html", "/index.html",
"/manifest.webmanifest", "/manifest.webmanifest",
"/*.css", "/*.css",
+10
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

+32 -18
View File
@@ -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"
} }
] ]
} }
+18 -19
View File
@@ -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'))
+1 -2
View File
@@ -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'
}) })
], ],
+42 -1
View File
@@ -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>
+320
View File
@@ -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;
}
}
}
}
+151 -74
View File
@@ -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) {
+1 -1
View File
@@ -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
+1
View File
@@ -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() {}
+32 -14
View File
@@ -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);
+30 -6
View File
@@ -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', () => {
+74
View File
@@ -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');
});
});
+38 -12
View File
@@ -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}`;
} }
+56 -8
View File
@@ -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)
+6 -4
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = '2026.05.20.1624'; export const VERSION = '2026.05.22.0110';
Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

+2 -1
View File
@@ -1,4 +1,5 @@
export const environment = { export const environment = {
production: true, production: true,
contactEmail: 'info@canticristiani.it' contactEmail: 'info@canticristiani.it',
appName: 'CantiCristiani'
}; };
+2 -1
View File
@@ -4,7 +4,8 @@
export const environment = { export const environment = {
production: false, production: false,
contactEmail: 'info@canticristiani.it' contactEmail: 'info@canticristiani.it',
appName: 'CantiCristiani'
}; };
/* /*
+6
View File
@@ -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));