Compare commits

..

2 Commits

Author SHA1 Message Date
David Frassi b0bb5735c8 Fix PWA redirect loop and installation options after uninstalling PWA 2026-06-18 02:36:52 +02:00
David Frassi 93385097f2 feat: add PDF import support and improve OCR chord parsing and translation 2026-06-17 10:23:21 +02:00
27 changed files with 1035 additions and 214 deletions
+30 -17
View File
@@ -29,25 +29,38 @@ if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
exit 1 exit 1
fi fi
# --- Rotazione file su FTP --- # --- Rotazione e Caricamento file (FTP o VPS) ---
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..." if [ -n "$VPS_HOST" ]; then
# Rinominiamo canti.json in canti_ex.json. echo "🔄 Rotazione file su VPS ($VPS_HOST)..."
# Usiamo i percorsi assoluti per sicurezza. ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api && mv $VPS_PATH/api/canti.json $VPS_PATH/api/canti_ex.json 2>/dev/null || true"
curl -s -u "$FTP_USER:$FTP_PASS" \
--ftp-pasv \
-Q "*DELE /htdocs/api/canti_ex.json" \
-Q "*RNFR /htdocs/api/canti.json" \
-Q "*RNTO /htdocs/api/canti_ex.json" \
"ftp://$FTP_HOST/" > /dev/null
# --- Upload via FTP --- echo "🚀 Caricamento nuovo file su VPS via SCP..."
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..." if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then echo "✅ Allineamento su VPS completato con successo!"
echo "✅ Allineamento completato con successo!" else
echo "❌ Errore durante il caricamento via SCP su VPS."
rm "$TEMP_FILE"
exit 1
fi
else else
echo "❌ Errore durante il caricamento FTP." echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
rm "$TEMP_FILE" # Rinominiamo canti.json in canti_ex.json.
exit 1 # Usiamo i percorsi assoluti per sicurezza.
curl -s -u "$FTP_USER:$FTP_PASS" \
--ftp-pasv \
-Q "*DELE /htdocs/api/canti_ex.json" \
-Q "*RNFR /htdocs/api/canti.json" \
-Q "*RNTO /htdocs/api/canti_ex.json" \
"ftp://$FTP_HOST/" > /dev/null
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
echo "✅ Allineamento su FTP completato con successo!"
else
echo "❌ Errore durante il caricamento FTP."
rm "$TEMP_FILE"
exit 1
fi
fi fi
# Cleanup # Cleanup
+19 -7
View File
@@ -29,14 +29,26 @@ if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"
exit 1 exit 1
fi fi
# --- Upload via FTP --- # --- Caricamento file (FTP o VPS) ---
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..." if [ -n "$VPS_HOST" ]; then
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then echo "🚀 Caricamento nuovo file su VPS ($VPS_HOST) via SCP..."
echo "✅ Allineamento completato con successo!" ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api"
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
echo "✅ Allineamento su VPS completato con successo!"
else
echo "❌ Errore durante il caricamento via SCP su VPS."
rm "$TEMP_FILE"
exit 1
fi
else else
echo "❌ Errore durante il caricamento FTP." echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
rm "$TEMP_FILE" if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
exit 1 echo "✅ Allineamento su FTP completato con successo!"
else
echo "❌ Errore durante il caricamento FTP."
rm "$TEMP_FILE"
exit 1
fi
fi fi
# Cleanup # Cleanup
+1 -1
View File
@@ -89,5 +89,5 @@ if [ "$TARGET" = "tophost" ]; then
python3 scratch/deploy_ftp.py python3 scratch/deploy_ftp.py
else else
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..." echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
rsync -avz --delete www/ "$VPS_USER@$VPS_HOST:$VPS_PATH" rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
fi fi
+3
View File
@@ -6,6 +6,9 @@
"start_url": "/", "start_url": "/",
"theme_color": "#3880ff", "theme_color": "#3880ff",
"background_color": "#ffffff", "background_color": "#ffffff",
"launch_handler": {
"client_mode": "focus-existing"
},
"protocol_handlers": [ "protocol_handlers": [
{ {
"protocol": "web+canti", "protocol": "web+canti",
+68 -51
View File
@@ -4,82 +4,99 @@
<!-- PWA Redirect Overlay --> <!-- PWA Redirect Overlay -->
<div *ngIf="showRedirectOverlay()" <div *ngIf="showRedirectOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;"> style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);"> <div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
<div style="margin-bottom: 25px; display: inline-block;"> <div style="margin-bottom: 25px; display: inline-block;">
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);"> <img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
</div> </div>
<!-- Fase di redirect normale (in attesa di apertura) --> <!-- Se la PWA è rilevata come installata -->
<ng-container *ngIf="!redirectFailed()"> <ng-container *ngIf="isPwaInstalled()">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2> <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; color: #ffffff; -webkit-font-smoothing: antialiased;">Applicazione Installata</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;"> <div style="font-size: 1.05rem; color: rgba(255, 255, 255, 0.9); line-height: 1.6; -webkit-font-smoothing: antialiased; text-align: center; padding: 0 10px; margin-bottom: 25px;">
Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta. La app risulta già installata sul dispositivo, chiudi il browser ed usa quella oppure disinstallala se preferisci utilizzarla da qui.
</p> </div>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;"> <div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button (click)="openPwaManual()" <button (click)="stayInBrowserForceUninstallCheck()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);"> style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
Apri Manualmente Continua nel browser (L'ho disinstallata)
</button>
<button (click)="stayInBrowser()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
Rimani nel browser
</button> </button>
</div> </div>
</ng-container> </ng-container>
<!-- Se l'apertura automatica/manuale fallisce (PWA probabilmente non installata) --> <!-- Se l'applicazione NON è installata (flusso normale di redirect / installazione guidata) -->
<ng-container *ngIf="redirectFailed()"> <ng-container *ngIf="!isPwaInstalled()">
<!-- iOS Guide --> <!-- Fase di redirect normale (in attesa di apertura) -->
<div *ngIf="settingsService.isIos()" style="text-align: left;"> <ng-container *ngIf="!redirectFailed()">
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; text-align: center; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2> <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; text-align: center; margin-bottom: 20px; -webkit-font-smoothing: antialiased;">
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
</p>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
</div>
</div>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> .
</div>
</div>
</div>
<button (click)="stayInBrowser()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); width: 100%; text-align: center;">
Continua nel browser
</button>
</div>
<!-- Android / Desktop -->
<div *ngIf="!settingsService.isIos()" style="text-align: center;">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;"> <p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser ed usarla offline! Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
</p> </p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;"> <div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button *ngIf="settingsService.showInstallButton() || settingsService.deferredPrompt()" (click)="triggerInstallFromRedirect()" <button (click)="openPwaManual()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);"> style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
INSTALLA APP Apri Manualmente
</button> </button>
<div style="margin-top: 15px; font-size: 0.85rem; color: rgba(255, 255, 255, 0.5); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 0 10px; text-align: center;">
Per evitare problemi di cache, la navigazione da browser è disattivata se la PWA è installata.<br>
Se preferisci usare il browser, disinstalla l'app dal dispositivo.
</div>
</div>
</ng-container>
<!-- Se l'apertura automatica/manuale fallisce (PWA probabilmente non installata) -->
<ng-container *ngIf="redirectFailed()">
<!-- iOS Guide -->
<div *ngIf="settingsService.isIos()" style="text-align: left;">
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; text-align: center; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2>
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; text-align: center; margin-bottom: 20px; -webkit-font-smoothing: antialiased;">
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
</p>
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
</div>
</div>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> .
</div>
</div>
</div>
<button (click)="stayInBrowser()" <button (click)="stayInBrowser()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;"> style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); width: 100%; text-align: center;">
Continua nel browser Continua nel browser
</button> </button>
</div> </div>
</div>
<!-- Android / Desktop -->
<div *ngIf="!settingsService.isIos()" style="text-align: center;">
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser ed usarla offline!
</p>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
<button *ngIf="settingsService.showInstallButton() || settingsService.deferredPrompt()" (click)="triggerInstallFromRedirect()"
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
INSTALLA APP
</button>
<button (click)="stayInBrowser()"
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
Continua nel browser
</button>
</div>
</div>
</ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
<!-- PWA Install Overlay --> <!-- PWA Install Overlay -->
<div *ngIf="showInstallOverlay()" <div *ngIf="showInstallOverlay()"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;"> style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
<!-- Android Install Prompt --> <!-- Android Install Prompt -->
<div *ngIf="settingsService.isAndroid()" <div *ngIf="settingsService.isAndroid()"
+183 -30
View File
@@ -26,6 +26,9 @@ export class AppComponent implements OnInit {
public showRedirectOverlay = signal<boolean>(false); public showRedirectOverlay = signal<boolean>(false);
public showInstallOverlay = signal<boolean>(false); public showInstallOverlay = signal<boolean>(false);
public isInstalling = signal<boolean>(false);
public isRedirecting = signal<boolean>(false);
public isPwaInstalled = signal<boolean>(false);
public redirectFailed = signal<boolean>(false); public redirectFailed = signal<boolean>(false);
public protocolLink = ''; public protocolLink = '';
@@ -45,13 +48,12 @@ export class AppComponent implements OnInit {
return; return;
} }
// Se l'overlay di redirect o di installazione è mostrato, NON nascondiamo il loader iniziale // Se stiamo attivamente installando o reindirizzando, NON nascondiamo il loader
// per rimanere nella welcome page mentre l'utente sceglie. if (this.isInstalling() || this.isRedirecting()) {
if (this.showRedirectOverlay() || this.showInstallOverlay()) {
return; return;
} }
// Altrimenti, nascondiamo il loader per far entrare l'utente nell'app // Altrimenti, nascondiamo il loader per far entrare l'utente
if ((window as any).PwaLoader) { if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide(); (window as any).PwaLoader.hide();
} }
@@ -95,6 +97,15 @@ export class AppComponent implements OnInit {
// Set signal indicating startup version check is complete // Set signal indicating startup version check is complete
this.settingsService.isVersionCheckComplete.set(true); this.settingsService.isVersionCheckComplete.set(true);
// Handle PWA Launch Queue if supported (focus-existing launch behavior)
if ('launchQueue' in window) {
(window as any).launchQueue.setConsumer((launchParams: any) => {
if (launchParams.targetURL) {
this.handleLaunchUrl(launchParams.targetURL);
}
});
}
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
const protocolUrl = params['url']; const protocolUrl = params['url'];
if (protocolUrl && protocolUrl.startsWith('web+canti:')) { if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
@@ -120,10 +131,73 @@ export class AppComponent implements OnInit {
} }
} }
}); });
window.addEventListener('appinstalled', () => {
console.log('[AppComponent] PWA appinstalled event caught.');
localStorage.setItem('pwa-installed', 'true');
this.isPwaInstalled.set(true);
this.isInstalling.set(false);
this.isRedirecting.set(true);
this.showInstallOverlay.set(false);
this.showRedirectOverlay.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Chiudi il browser',
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
setTimeout(() => {
window.location.href = this.protocolLink;
}, 1000);
});
this.checkAndRedirectToPwa(); this.checkAndRedirectToPwa();
} }
handleLaunchUrl(urlStr: string) {
try {
const urlObj = new URL(urlStr);
// 1. Check if it's a protocol link inside query params (e.g. /?url=web+canti://...)
const protocolUrl = urlObj.searchParams.get('url');
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
const innerUrlObj = new URL(cleanUrl);
let targetPath = innerUrlObj.pathname;
if (targetPath === '/open' || targetPath === '//open') {
targetPath = '/';
} else if (targetPath.startsWith('/open/')) {
targetPath = targetPath.substring(5);
}
const queryParams: any = {};
innerUrlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
console.log('[AppComponent] Launch queue routing (protocol) to:', targetPath, queryParams);
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
return;
}
// 2. Otherwise, route directly to the pathname and query params of the URL
let targetPath = urlObj.pathname;
const queryParams: any = {};
urlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
console.log('[AppComponent] Launch queue routing (direct) to:', targetPath, queryParams);
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
} catch (e) {
console.error('Failed to parse launch url:', urlStr, e);
}
}
async checkVersionSync(): Promise<boolean> { async checkVersionSync(): Promise<boolean> {
// Controlla SEMPRE version.json per primo — è il modo più affidabile per // Controlla SEMPRE version.json per primo — è il modo più affidabile per
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW. // rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
@@ -304,30 +378,49 @@ export class AppComponent implements OnInit {
} }
} }
if (isInstalled) { // Se non è rilevata in localStorage/relatedApps ed è Android o Desktop con supporto ai prompt:
const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true'; // attendiamo 1.5s per dare tempo all'evento 'beforeinstallprompt' di scattare.
if (!skipRedirect) { // Se non scatta, significa che l'app è già installata.
this.showRedirectOverlay.set(true); if (!isInstalled && !this.settingsService.isIos() && ('onbeforeinstallprompt' in window)) {
this.redirectFailed.set(false); await new Promise(resolve => setTimeout(resolve, 1500));
if ((window as any).PwaLoader) { isInstalled = localStorage.getItem('pwa-installed') === 'true';
(window as any).PwaLoader.update({ isRedirect: true }); if (!isInstalled) {
const hasPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
if (!hasPrompt) {
console.log('[AppComponent] PWA detected as already installed (onbeforeinstallprompt supported but no prompt fired).');
isInstalled = true;
localStorage.setItem('pwa-installed', 'true');
} }
// Tentiamo il reindirizzamento automatico
setTimeout(() => {
window.location.href = this.protocolLink;
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
setTimeout(() => {
if (this.showRedirectOverlay()) {
this.redirectFailed.set(true);
localStorage.setItem('pwa-installed', 'false');
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
}
}, 2000);
}, 800);
} }
}
this.isPwaInstalled.set(isInstalled);
if (isInstalled) {
if (sessionStorage.getItem('skip-pwa-redirect') === 'true') {
this.showRedirectOverlay.set(false);
this.checkLoaderDismissal();
return;
}
this.showRedirectOverlay.set(true);
this.redirectFailed.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ isRedirect: true });
}
// Tentiamo il reindirizzamento automatico
setTimeout(() => {
window.location.href = this.protocolLink;
// Se dopo 2 secondi l'utente è ancora qui, mostriamo lo stato fallito per aprire manualmente o indicare la disinstallazione
setTimeout(() => {
if (this.showRedirectOverlay()) {
this.redirectFailed.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
}
}, 2000);
}, 800);
} else { } else {
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale // Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true'; const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
@@ -335,6 +428,8 @@ export class AppComponent implements OnInit {
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt(); const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
if (!skipInstall && (isMobile || hasDesktopPrompt)) { if (!skipInstall && (isMobile || hasDesktopPrompt)) {
this.showInstallOverlay.set(true); this.showInstallOverlay.set(true);
} else {
this.checkLoaderDismissal();
} }
} }
} }
@@ -360,22 +455,80 @@ export class AppComponent implements OnInit {
stayInBrowser() { stayInBrowser() {
sessionStorage.setItem('skip-pwa-redirect', 'true'); sessionStorage.setItem('skip-pwa-redirect', 'true');
this.showRedirectOverlay.set(false); this.showRedirectOverlay.set(false);
this.checkLoaderDismissal();
}
stayInBrowserForceUninstallCheck() {
localStorage.setItem('pwa-installed', 'false');
this.isPwaInstalled.set(false);
this.stayInBrowser();
} }
closeInstallOverlay() { closeInstallOverlay() {
sessionStorage.setItem('skip-pwa-install', 'true'); sessionStorage.setItem('skip-pwa-install', 'true');
this.showInstallOverlay.set(false); this.showInstallOverlay.set(false);
this.checkLoaderDismissal();
} }
async triggerInstall() { async triggerInstall() {
await this.settingsService.installPwa(); this.isInstalling.set(true);
this.closeInstallOverlay(); this.showInstallOverlay.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Installazione in corso...',
desc: 'Completa l\'installazione tramite la finestra del browser.'
});
}
const outcome = await this.settingsService.installPwa();
if (outcome === 'accepted') {
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Apertura Applicazione...',
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
} else {
this.isInstalling.set(false);
this.isRedirecting.set(false);
this.showInstallOverlay.set(true);
this.checkLoaderDismissal();
}
} }
async triggerInstallFromRedirect() { async triggerInstallFromRedirect() {
await this.settingsService.installPwa(); this.isInstalling.set(true);
sessionStorage.setItem('skip-pwa-redirect', 'true');
this.showRedirectOverlay.set(false); this.showRedirectOverlay.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.show();
(window as any).PwaLoader.update({
title: 'Installazione in corso...',
desc: 'Completa l\'installazione tramite la finestra del browser.'
});
}
const outcome = await this.settingsService.installPwa();
if (outcome === 'accepted') {
sessionStorage.setItem('skip-pwa-redirect', 'true');
this.isInstalling.set(false);
this.isRedirecting.set(true);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({
title: 'Apertura Applicazione...',
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
isRedirect: true
});
}
} else {
this.isInstalling.set(false);
this.isRedirecting.set(false);
this.showRedirectOverlay.set(true);
this.checkLoaderDismissal();
}
} }
} }
+1 -1
View File
@@ -379,7 +379,7 @@
</div> </div>
</div> </div>
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())"> <ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="(comunitaService.isFilterActive() && comunitaService.comunitaCode()) || limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
<ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti..."> <ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
</ion-infinite-scroll-content> </ion-infinite-scroll-content>
</ion-infinite-scroll> </ion-infinite-scroll>
+8 -3
View File
@@ -148,10 +148,9 @@ ion-item.glass {
--padding-start: 16px; --padding-start: 16px;
--inner-padding-end: 16px; --inner-padding-end: 16px;
margin-bottom: 12px; margin-bottom: 12px;
transition: transform 0.2s ease, background 0.2s ease; transition: background 0.2s ease;
&:active { &:active {
transform: scale(0.98);
--background: rgba(255, 255, 255, 0.1); --background: rgba(255, 255, 255, 0.1);
} }
} }
@@ -426,7 +425,8 @@ ion-title {
width: 100%; width: 100%;
.selection-column { .selection-column {
padding: 10px 14px 10px 0; padding: 12px 18px 12px 12px;
margin-left: -12px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -443,6 +443,11 @@ ion-title {
padding: 10px 0; padding: 10px 0;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: transform 0.2s ease;
&:active {
transform: scale(0.98);
}
.top-row { .top-row {
display: flex; display: flex;
+6
View File
@@ -427,6 +427,9 @@ export class HomePage implements OnDestroy {
if (this.playlistService.selectionMode() && !this.isAddingSongs()) { if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
return this.filteredCanti(); return this.filteredCanti();
} }
if (this.comunitaService.isFilterActive() && this.comunitaService.comunitaCode()) {
return this.filteredCanti();
}
return this.filteredCanti().slice(0, this.limit()); return this.filteredCanti().slice(0, this.limit());
}); });
@@ -1647,6 +1650,9 @@ export class HomePage implements OnDestroy {
const songSettings = pl ? pl.songSettings : undefined; const songSettings = pl ? pl.songSettings : undefined;
await this.playlistService.savePlaylist(data.name, ids, songSettings); await this.playlistService.savePlaylist(data.name, ids, songSettings);
// Azzoppa / azzera il filtro cerca libera quando si salva
this.searchQuery.set('');
const toast = await this.toastCtrl.create({ const toast = await this.toastCtrl.create({
message: 'Playlist salvata!', message: 'Playlist salvata!',
duration: 2000, duration: 2000,
+1
View File
@@ -53,6 +53,7 @@
min-height: 0.5em; min-height: 0.5em;
font-family: 'Outfit', sans-serif; font-family: 'Outfit', sans-serif;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding-right: 0.25em;
} }
.active-line .chord { .active-line .chord {
+1
View File
@@ -200,6 +200,7 @@
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
height: 1.2em; height: 1.2em;
margin-bottom: -0.2em; margin-bottom: -0.2em;
padding-right: 0.25em;
} }
.seg-text { .seg-text {
+9 -6
View File
@@ -1072,8 +1072,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
// Try to fetch YouTube thumbnail first if available // Try to fetch YouTube thumbnail first if available
if (thumbUrl) { if (thumbUrl) {
try { try {
// Use images.weserv.nl as a CORS proxy to bypass YouTube's CORS policy
const proxiedUrl = `https://images.weserv.nl/?url=${encodeURIComponent(thumbUrl)}`;
const response = await Promise.race([ const response = await Promise.race([
fetch(thumbUrl), fetch(proxiedUrl),
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000)) new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
]); ]);
if (response.ok) { if (response.ok) {
@@ -1083,10 +1085,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} catch (e) { } catch (e) {
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e); console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
} }
} } else {
// If no YouTube thumb is available, fallback to local canticristiani logo (favicon)
// If no YouTube thumb or fetch failed, fallback to local canticristiani logo (favicon)
if (!fileToShare) {
try { try {
const response = await fetch('assets/icon/favicon.png'); const response = await fetch('assets/icon/favicon.png');
if (response.ok) { if (response.ok) {
@@ -1106,7 +1106,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
url: shareLink url: shareLink
}; };
if (fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) { const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) {
shareDataObj.files = [fileToShare]; shareDataObj.files = [fileToShare];
} }
+4 -1
View File
@@ -178,7 +178,10 @@ export class PlaylistPage {
const blob = await res.blob(); const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' }); const file = new File([blob], fileName, { type: 'image/png' });
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ await navigator.share({
files: [file], files: [file],
title: 'Playlist CantiCristiani', title: 'Playlist CantiCristiani',
@@ -16,8 +16,8 @@
<div class="drag-overlay" *ngIf="isDraggingOver"> <div class="drag-overlay" *ngIf="isDraggingOver">
<div class="drag-message"> <div class="drag-message">
<ion-icon name="image-outline"></ion-icon> <ion-icon name="document-text-outline"></ion-icon>
<p>Rilascia l'immagine qui per estrarre il testo</p> <p>Rilascia l'immagine o il PDF qui per estrarre il testo</p>
</div> </div>
</div> </div>
@@ -109,6 +109,9 @@
<ion-button fill="clear" size="small" (click)="takePhoto()"> <ion-button fill="clear" size="small" (click)="takePhoto()">
<ion-icon name="camera-outline"></ion-icon> <ion-icon name="camera-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button fill="clear" size="small" (click)="chooseFile()">
<ion-icon name="document-attach-outline"></ion-icon>
</ion-button>
</div> </div>
</div> </div>
<ion-item class="custom-input-item textarea-item"> <ion-item class="custom-input-item textarea-item">
@@ -191,4 +194,5 @@
<!-- Hidden inputs --> <!-- Hidden inputs -->
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;"> <input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
</ion-content> </ion-content>
@@ -615,6 +615,7 @@ body.high-contrast :host ::ng-deep {
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
height: 1.25em; height: 1.25em;
margin-bottom: -0.2em; margin-bottom: -0.2em;
padding-right: 0.25em;
} }
.preview-seg-text { .preview-seg-text {
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProposeCantoPage } from './propose-canto.page'; import { ProposeCantoPage } from './propose-canto.page';
import { ToastController, PopoverController, NavController, AngularDelegate } from '@ionic/angular'; import { ToastController, PopoverController, NavController, AngularDelegate, AlertController } from '@ionic/angular';
import { CantiService } from '../../services/canti.service'; import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service'; import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service'; import { PlaylistService } from '../../services/playlist.service';
@@ -41,6 +41,7 @@ describe('ProposeCantoPage', () => {
const navCtrlMock = {}; const navCtrlMock = {};
const toastControllerMock = {}; const toastControllerMock = {};
const popoverControllerMock = {}; const popoverControllerMock = {};
const alertControllerMock = {};
const angularDelegateMock = {}; const angularDelegateMock = {};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -55,7 +56,8 @@ describe('ProposeCantoPage', () => {
{ provide: NavController, useValue: navCtrlMock }, { provide: NavController, useValue: navCtrlMock },
{ provide: ToastController, useValue: toastControllerMock }, { provide: ToastController, useValue: toastControllerMock },
{ provide: PopoverController, useValue: popoverControllerMock }, { provide: PopoverController, useValue: popoverControllerMock },
{ provide: AngularDelegate, useValue: angularDelegateMock } { provide: AngularDelegate, useValue: angularDelegateMock },
{ provide: AlertController, useValue: alertControllerMock }
] ]
}); });
@@ -124,4 +126,26 @@ describe('ProposeCantoPage', () => {
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#'); expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
}); });
}); });
describe('isChordWord', () => {
it('should identify Italian and English chords correctly based on mode', () => {
expect(component.isChordWord('DO', true)).toBe(true);
expect(component.isChordWord('DO', false)).toBe(true);
expect(component.isChordWord('C', true)).toBe(false); // English chord in Italian notation -> false
expect(component.isChordWord('C', false)).toBe(true); // English chord in English notation -> true
expect(component.isChordWord('Lan', true)).toBe(true);
expect(component.isChordWord('Lan', false)).toBe(true);
});
});
describe('isItalianNotation', () => {
it('should detect Italian notation if DO/RE/MI/SOL/SI are present', () => {
const words = [{ text: 'Lan' }, { text: 'FA' }, { text: 'DO' }, { text: 'SOL' }];
expect(component.isItalianNotation(words)).toBe(true);
});
it('should return false if only English-like or non-italian chords are present', () => {
const words = [{ text: 'C' }, { text: 'G' }, { text: 'D' }];
expect(component.isItalianNotation(words)).toBe(false);
});
});
}); });
+497 -64
View File
@@ -1,7 +1,7 @@
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core'; import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController } from '@ionic/angular'; import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular';
import { createWorker } from 'tesseract.js'; import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service'; import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service'; import { MyCantiService } from '../../services/my-canti.service';
@@ -20,6 +20,7 @@ import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser
export class ProposeCantoPage implements OnInit { export class ProposeCantoPage implements OnInit {
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea; @ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef; @ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
@ViewChild('fileInput', { static: false }) fileInput!: ElementRef;
public cantiService = inject(CantiService); public cantiService = inject(CantiService);
private myCantiService = inject(MyCantiService); private myCantiService = inject(MyCantiService);
@@ -29,6 +30,7 @@ export class ProposeCantoPage implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
public lyricsParser = inject(LyricsParserService); public lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController);
showChordsPreview: boolean = true; showChordsPreview: boolean = true;
@@ -189,6 +191,10 @@ export class ProposeCantoPage implements OnInit {
this.cameraInput.nativeElement.click(); this.cameraInput.nativeElement.click();
} }
chooseFile() {
this.fileInput.nativeElement.click();
}
onDragOver(event: DragEvent) { onDragOver(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -212,11 +218,12 @@ export class ProposeCantoPage implements OnInit {
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];
if (file.type.indexOf('image') !== -1) { const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
await this.processImageFile(file); if (file.type.indexOf('image') !== -1 || isPdf) {
await this.processFile(file);
} else { } else {
const toast = await this.toastController.create({ const toast = await this.toastController.create({
message: 'Per favore, trascina un file immagine valido.', message: 'Per favore, trascina un file immagine o PDF valido.',
duration: 3000, duration: 3000,
color: 'warning' color: 'warning'
}); });
@@ -231,7 +238,7 @@ export class ProposeCantoPage implements OnInit {
console.log('[OCR-Capture] Nessun file selezionato.'); console.log('[OCR-Capture] Nessun file selezionato.');
return; return;
} }
await this.processImageFile(file); await this.processFile(file);
event.target.value = ''; event.target.value = '';
} }
@@ -239,50 +246,335 @@ export class ProposeCantoPage implements OnInit {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
let hasImage = false;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) { if (items[i].type.indexOf('image') !== -1) {
hasImage = true;
event.preventDefault(); // Prevent pasting the image representation as text event.preventDefault(); // Prevent pasting the image representation as text
const blob = items[i].getAsFile(); const blob = items[i].getAsFile();
if (blob) { if (blob) {
const file = new File([blob], 'pasted-image.png', { type: blob.type }); const file = new File([blob], 'pasted-image.png', { type: blob.type });
await this.processImageFile(file); await this.processFile(file);
} }
break; break;
} }
} }
if (!hasImage) {
const pastedText = event.clipboardData?.getData('text');
if (pastedText && this.looksLikeChordSheetOrSong(pastedText)) {
event.preventDefault();
const parsed = this.parsePastedChordSheet(pastedText);
this.insertText(parsed);
}
}
} }
async processImageFile(file: File) { isTextLineChords(line: string, isItalian: boolean): boolean {
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`); const trimmed = line.trim();
if (!trimmed) return false;
const tokens = trimmed.split(/\s+/);
let chordCount = 0;
let nonChordWordCount = 0;
for (const token of tokens) {
let clean = token.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(clean, isItalian)) {
chordCount++;
} else {
if (clean.length > 4) {
nonChordWordCount++;
}
}
}
if (tokens.length === 0) return false;
const ratio = chordCount / tokens.length;
return ratio >= 0.6 && nonChordWordCount === 0;
}
getSectionType(line: string): 'chorus' | 'verse' | 'intro' | 'none' {
const trimmed = line.trim();
if (/^(ritornello|rit|chorus|refrain|coro)/i.test(trimmed)) {
return 'chorus';
}
if (/^(verso|strofa|verse|strophe|\d+(\.)?)/i.test(trimmed)) {
return 'verse';
}
if (/^(intro|introduzione|bridge|special|outro|strum)/i.test(trimmed)) {
return 'intro';
}
return 'none';
}
looksLikeChordSheetOrSong(text: string): boolean {
const lines = text.split('\n');
if (lines.length < 2) return false;
const words = text.split(/\s+/).map(t => ({ text: t }));
const isItalian = this.isItalianNotation(words);
let chordLinesCount = 0;
let sectionMarkersCount = 0;
for (const line of lines) {
if (this.getSectionType(line) !== 'none') {
sectionMarkersCount++;
}
if (this.isTextLineChords(line, isItalian)) {
chordLinesCount++;
}
}
return chordLinesCount > 0 || sectionMarkersCount > 0;
}
convertPureChordLine(line: string, isItalian: boolean): string {
return line.replace(/\S+/g, (match) => {
let clean = match.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(clean, isItalian)) {
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
}
return match;
});
}
parsePastedChordSheet(text: string): string {
const lines = text.split('\n');
const processedLines: string[] = [];
const words = text.split(/\s+/).map(t => ({ text: t }));
const isItalian = this.isItalianNotation(words);
let inVerse = false;
let inChorus = false;
let hasAccumulatedLines = false;
const closeSection = () => {
if (inChorus) {
processedLines.push('{end_chorus}');
inChorus = false;
}
if (inVerse) {
processedLines.push('{end_verse}');
inVerse = false;
}
hasAccumulatedLines = false;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
if (hasAccumulatedLines) {
closeSection();
processedLines.push('');
}
continue;
}
const sectionType = this.getSectionType(line);
if (sectionType !== 'none') {
closeSection();
if (sectionType === 'chorus') {
processedLines.push('{start_chorus}');
inChorus = true;
} else {
processedLines.push('{start_verse}');
inVerse = true;
}
continue;
}
// If no section is open, open a default one (verse)
if (!inChorus && !inVerse) {
processedLines.push('{start_verse}');
inVerse = true;
}
// Check if this line is chords
const isChords = this.isTextLineChords(line, isItalian);
if (isChords) {
// Look ahead to see if the next line is lyrics (not empty, not chords, not section marker)
let nextLine = '';
let nextLineIndex = i + 1;
while (nextLineIndex < lines.length) {
const nextTrimmed = lines[nextLineIndex].trim();
if (nextTrimmed) {
if (this.getSectionType(lines[nextLineIndex]) === 'none' && !this.isTextLineChords(lines[nextLineIndex], isItalian)) {
nextLine = lines[nextLineIndex];
}
break;
}
nextLineIndex++;
}
if (nextLine) {
// Merge spatially!
const chords: { text: string; index: number }[] = [];
const regex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
let clean = match[0].toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(clean, isItalian)) {
chords.push({
text: `[${this.convertEnglishChordToItalian(clean, isItalian)}]`,
index: match.index
});
} else {
chords.push({
text: match[0],
index: match.index
});
}
}
// Merge chords with the next line (lyrics)
let merged = '';
let lyricIdx = 0;
let chordIdx = 0;
while (lyricIdx < nextLine.length || chordIdx < chords.length) {
if (chordIdx < chords.length && (lyricIdx === chords[chordIdx].index || lyricIdx >= nextLine.length)) {
merged += chords[chordIdx].text;
chordIdx++;
} else {
merged += nextLine[lyricIdx];
lyricIdx++;
}
}
processedLines.push(merged);
hasAccumulatedLines = true;
// Skip the next line since we consumed it
i = nextLineIndex;
} else {
// No lyrics line follows, just convert chords in place
processedLines.push(this.convertPureChordLine(line, isItalian));
hasAccumulatedLines = true;
}
} else {
// Plain text line
processedLines.push(this.wrapChords(line, this.getChordRegex(isItalian), isItalian));
hasAccumulatedLines = true;
}
}
closeSection();
return processedLines.join('\n');
}
async convertPdfToImages(file: File): Promise<Blob[]> {
console.log('[OCR-Capture] Caricamento PDF per conversione in immagini...');
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.js';
const arrayBuffer = await file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
console.log(`[OCR-Capture] PDF caricato con successo. Numero di pagine: ${pdf.numPages}`);
const blobs: Blob[] = [];
// Limit to maximum 5 pages to avoid extreme memory consumption or timeouts
const pagesToRender = Math.min(pdf.numPages, 5);
for (let i = 1; i <= pagesToRender; i++) {
console.log(`[OCR-Capture] Rendering pagina ${i}/${pagesToRender}...`);
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 2.0 }); // High resolution scale for OCR
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Impossibile ottenere il context 2D per il canvas.');
}
await page.render({
canvasContext: ctx,
viewport: viewport,
canvas: canvas
}).promise;
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(b => resolve(b), 'image/jpeg', 0.95);
});
if (blob) {
blobs.push(blob);
}
}
return blobs;
}
async processFile(file: File) {
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB, è PDF: ${isPdf}`);
this.isProcessingOCR = true; this.isProcessingOCR = true;
this.ocrProgress = 0; this.ocrProgress = 0;
try { try {
console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...'); if (isPdf) {
const compressedBlob = await this.resizeImage(file); const pageBlobs = await this.convertPdfToImages(file);
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' }); console.log(`[OCR-Capture] PDF convertito in ${pageBlobs.length} immagini.`);
console.log(`[OCR-Capture] Dimensioni dopo compressione: ${(compressedFile.size / 1024).toFixed(1)} KB`);
console.log('[OCR-Capture] Avvio motore Tesseract OCR local...'); for (let idx = 0; idx < pageBlobs.length; idx++) {
const extractedText = await this.processImageOCR(compressedFile); const pageBlob = pageBlobs[idx];
const pageFile = new File([pageBlob], `page_${idx + 1}.jpg`, { type: 'image/jpeg' });
console.log(`[OCR-Capture] Elaborazione pagina ${idx + 1}/${pageBlobs.length} via OCR...`);
this.ocrProgress = (idx / pageBlobs.length);
const extractedText = await this.processImageOCR(pageFile);
if (extractedText) {
console.log(`[OCR-Capture] Pagina ${idx + 1} estratta con successo.`);
this.content += (this.content ? '\n\n' : '') + extractedText;
}
}
if (extractedText) {
console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
this.content += (this.content ? '\n\n' : '') + extractedText;
const toast = await this.toastController.create({ const toast = await this.toastController.create({
message: 'Scansione completata!', message: 'Scansione PDF completata!',
duration: 2000, duration: 2000,
color: 'success' color: 'success'
}); });
toast.present(); toast.present();
} else { } else {
console.warn('[OCR-Capture] Nessun testo estratto dal file.'); console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
const compressedBlob = await this.resizeImage(file);
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
console.log(`[OCR-Capture] Dimensioni dopo compressione: ${(compressedFile.size / 1024).toFixed(1)} KB`);
console.log('[OCR-Capture] Avvio motore Tesseract OCR local...');
const extractedText = await this.processImageOCR(compressedFile);
if (extractedText) {
console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
this.content += (this.content ? '\n\n' : '') + extractedText;
const toast = await this.toastController.create({
message: 'Scansione completata!',
duration: 2000,
color: 'success'
});
toast.present();
} else {
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
}
} }
} catch (error) { } catch (error) {
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error); console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
const errorToast = await this.toastController.create({ const errorToast = await this.toastController.create({
message: 'Errore durante la scansione dell\'immagine.', message: 'Errore durante la scansione del file.',
duration: 3000, duration: 3000,
color: 'danger' color: 'danger'
}); });
@@ -383,6 +675,13 @@ export class ProposeCantoPage implements OnInit {
sanitizeOcrChord(text: string): string { sanitizeOcrChord(text: string): string {
if (!text) return text; if (!text) return text;
// First, heal separators like I, 1, l, |, \ between chords/notes to '/'
const separatorRegex = /\b([A-G]|DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?(m|min|maj|dim|aug|sus)?(\d*)([I1l|\\\/])([A-G]|DO|RE|MI|FA|SOL|LA|SI|\d+)(#|b|♭)?\b/gi;
text = text.replace(separatorRegex, (match, p1, p2, p3, p4, sep, p5, p6) => {
return `${p1}${p2 || ''}${p3 || ''}${p4 || ''}/${p5}${p6 || ''}`;
});
if (text.includes('/')) { if (text.includes('/')) {
return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/'); return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/');
} }
@@ -397,13 +696,137 @@ export class ProposeCantoPage implements OnInit {
cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#'); cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#');
// Clean duplicate sharps (e.g. ## -> #) // Clean duplicate sharps (e.g. ## -> #)
cleaned = cleaned.replace(/##+/g, '#'); cleaned = cleaned.replace(/##+/g, '#');
// Clean duplicate chord letters at start (e.g. Ff#m -> F#m)
cleaned = cleaned.replace(/^([CDEFGAB])\1/gi, '$1');
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
cleaned = cleaned.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
return p1 + (p2 || '') + 'm' + (p3 || '');
});
return cleaned; return cleaned;
} }
convertEnglishChordToItalian(chord: string): string { getChordRegex(isItalian: boolean): RegExp {
if (isItalian) {
return /^(DO|RE|MI|FA|SOL|LA|SI)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|\d+)(#|B|b)?)?$/i;
} else {
return /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i;
}
}
getMultiChordRegex(isItalian: boolean): RegExp {
if (isItalian) {
return /((?:DO|RE|MI|FA|SOL|LA|SI)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-|4|5|6)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|\d+)(?:#|B|b)?)?)/gi;
} else {
return /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-|4|5|6)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(?:#|B|b)?)?)/gi;
}
}
isItalianNotation(words: any[]): boolean {
if (!words || words.length === 0) return false;
// 1. Group words into horizontal lines
const heights = words.map(w => w.bbox ? (w.bbox.y1 - w.bbox.y0) : 10);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 1.0;
const lines: any[][] = [];
words.forEach(word => {
if (!word.text) return;
const wordYCenter = word.bbox ? ((word.bbox.y0 + word.bbox.y1) / 2) : 0;
let added = false;
for (const line of lines) {
const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox ? ((w.bbox.y0 + w.bbox.y1) / 2) : 0), 0) / line.length;
if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
line.push(word);
added = true;
break;
}
}
if (!added) {
lines.push([word]);
}
});
const genericChordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i;
let englishChordsCount = 0;
let italianChordsCount = 0;
lines.forEach(line => {
let chordCount = 0;
line.forEach(w => {
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (genericChordRegex.test(clean)) {
chordCount++;
}
});
const ratio = line.length > 0 ? chordCount / line.length : 0;
if (ratio >= 0.5) {
line.forEach(w => {
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
const isEnglish = /^(C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i.test(clean);
const isItalian = /^(DO|RE|MI|FA|SOL|LA|SI)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|\d+)(#|B|b)?)?$/i.test(clean);
if (isEnglish && !isItalian) {
englishChordsCount++;
} else if (isItalian && !isEnglish) {
italianChordsCount++;
}
});
}
});
if (englishChordsCount === 0 && italianChordsCount === 0) {
// Fallback: search the entire words list for unambiguous chords
words.forEach(w => {
if (!w.text) return;
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
const isUnambiguousEnglish =
/^(C|D|G|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i.test(clean) ||
/^(A|E|F)(#|B|M|-|MIN|MAJ|AUG|DIM|4|5|6|7|9|11|13)/i.test(clean);
const isUnambiguousItalian =
/^(DO|RE|MI|FA|SOL|LA|SI)(#|B|M|-|MIN|MAJ|AUG|DIM|4|5|6|7|9|11|13)/i.test(clean);
if (isUnambiguousEnglish) englishChordsCount++;
if (isUnambiguousItalian) italianChordsCount++;
});
}
console.log(`[OCR-Capture] Chord line analysis - English chords: ${englishChordsCount}, Italian chords: ${italianChordsCount}`);
return italianChordsCount > englishChordsCount;
}
isChordWord(text: string, isItalian: boolean): boolean {
if (!text || text.length === 0) return false;
const lower = text.toLowerCase();
// Skip common valid words written purely in lowercase
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
return false;
}
let clean = text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
const chordRegex = this.getChordRegex(isItalian);
if (clean.includes('_')) {
const parts = clean.split('_').filter(p => p.length > 0);
if (parts.length === 0) return false;
return parts.every(p => chordRegex.test(p));
}
return chordRegex.test(clean);
}
convertEnglishChordToItalian(chord: string, isItalian: boolean = false): string {
if (isItalian) return chord;
if (!chord) return chord; if (!chord) return chord;
if (chord.includes('/')) { if (chord.includes('/')) {
return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim())).join('/'); return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim(), isItalian)).join('/');
} }
const upper = chord.toUpperCase(); const upper = chord.toUpperCase();
if (upper.startsWith('DO')) { if (upper.startsWith('DO')) {
@@ -449,6 +872,9 @@ export class ProposeCantoPage implements OnInit {
return ''; return '';
} }
const isItalian = this.isItalianNotation(words);
console.log(`[OCR-Capture] Rilevata notazione italiana: ${isItalian}`);
// Preprocess words to split run-together chords like BA // Preprocess words to split run-together chords like BA
const preprocessedWords: any[] = []; const preprocessedWords: any[] = [];
words.forEach(w => { words.forEach(w => {
@@ -478,16 +904,17 @@ export class ProposeCantoPage implements OnInit {
// Calculate average word height to set vertical tolerance // Calculate average word height to set vertical tolerance
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0); const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length; const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 0.85; const verticalTolerance = avgHeight * 1.0;
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`); console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
// 2. Group words into horizontal lines // 2. Group words into horizontal lines
const lines: any[][] = []; const lines: any[][] = [];
validWords.forEach(word => { validWords.forEach(word => {
const wordYCenter = (word.bbox.y0 + word.bbox.y1) / 2;
let added = false; let added = false;
for (const line of lines) { for (const line of lines) {
const avgLineY0 = line.reduce((sum, w) => sum + w.bbox.y0, 0) / line.length; const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1) / 2, 0) / line.length;
if (Math.abs(word.bbox.y0 - avgLineY0) < verticalTolerance) { if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
line.push(word); line.push(word);
added = true; added = true;
break; break;
@@ -509,32 +936,14 @@ export class ProposeCantoPage implements OnInit {
}); });
// 3. Classify lines as Chords vs. Text // 3. Classify lines as Chords vs. Text
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i; const chordRegex = this.getChordRegex(isItalian);
const isChordWord = (text: string): boolean => {
if (!text || text.length === 0) return false;
const lower = text.toLowerCase();
// Skip common valid words written purely in lowercase
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
return false;
}
let clean = text.toUpperCase().replace(/\s+/g, '');
clean = clean.replace(/\((.*?)\)/g, '/$1');
clean = clean.replace(/[\.\,]$/g, '');
clean = this.sanitizeOcrChord(clean);
if (clean.includes('_')) {
const parts = clean.split('_').filter(p => p.length > 0);
if (parts.length === 0) return false;
return parts.every(p => chordRegex.test(p));
}
return chordRegex.test(clean);
};
const classifiedLines = lines.map(line => { const classifiedLines = lines.map(line => {
let chordCount = 0; let chordCount = 0;
let hasLongNonChord = false; let hasLongNonChord = false;
line.forEach(w => { line.forEach(w => {
if (isChordWord(w.text)) { if (this.isChordWord(w.text, isItalian)) {
chordCount++; chordCount++;
} else { } else {
const clean = w.text.replace(/[.,:;!\?]/g, '').trim(); const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
@@ -574,7 +983,7 @@ export class ProposeCantoPage implements OnInit {
const nextText = next ? next.words.map(w => w.text).join(' ') : ''; const nextText = next ? next.words.map(w => w.text).join(' ') : '';
if (next && !next.isChords && !this.isLabelLine(nextText)) { if (next && !next.isChords && !this.isLabelLine(nextText)) {
// Merge spatially! // Merge spatially!
const merged = this.mergeChordsAndLyrics(current.words, next.words); const merged = this.mergeChordsAndLyrics(current.words, next.words, isItalian);
const lineText = next.words.map(w => w.text).join(' '); const lineText = next.words.map(w => w.text).join(' ');
const isChorus = chorusStartRegex.test(lineText); const isChorus = chorusStartRegex.test(lineText);
@@ -590,7 +999,7 @@ export class ProposeCantoPage implements OnInit {
i++; // Skip next line because we consumed it! i++; // Skip next line because we consumed it!
} else { } else {
// Chord line but no text below it: just wrap and print // Chord line but no text below it: just wrap and print
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi; const multiChordRegex = this.getMultiChordRegex(isItalian);
const expandedChords: string[] = []; const expandedChords: string[] = [];
current.words.forEach(w => { current.words.forEach(w => {
const parts = w.text.split(/(_)/); const parts = w.text.split(/(_)/);
@@ -603,13 +1012,13 @@ export class ProposeCantoPage implements OnInit {
cleanText = cleanText.replace(/[\.\,]$/g, ''); cleanText = cleanText.replace(/[\.\,]$/g, '');
cleanText = this.sanitizeOcrChord(cleanText); cleanText = this.sanitizeOcrChord(cleanText);
if (chordRegex.test(cleanText)) { if (this.isChordWord(cleanText, isItalian)) {
return `[${this.convertEnglishChordToItalian(cleanText)}]`; return `[${this.convertEnglishChordToItalian(cleanText, isItalian)}]`;
} else { } else {
const matches = [...cleanText.matchAll(multiChordRegex)]; const matches = [...cleanText.matchAll(multiChordRegex)];
const fullMatchStr = matches.map(m => m[0]).join(''); const fullMatchStr = matches.map(m => m[0]).join('');
if (matches.length > 0 && fullMatchStr === cleanText) { if (matches.length > 0 && fullMatchStr === cleanText) {
return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase())}]`).join(' '); return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase(), isItalian)}]`).join(' ');
} else { } else {
return part; return part;
} }
@@ -634,7 +1043,7 @@ export class ProposeCantoPage implements OnInit {
inVerse = true; inVerse = true;
} }
processedLines.push(this.wrapChords(lineText, chordRegex)); processedLines.push(this.wrapChords(lineText, chordRegex, isItalian));
} }
// If we see a large vertical gap, close open blocks // If we see a large vertical gap, close open blocks
@@ -655,7 +1064,7 @@ export class ProposeCantoPage implements OnInit {
return processedLines.join('\n'); return processedLines.join('\n');
} }
mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string { mergeChordsAndLyrics(chordWords: any[], textWords: any[], isItalian: boolean = false): string {
// Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)' // Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
let mergedChordWords: any[] = []; let mergedChordWords: any[] = [];
for (let i = 0; i < chordWords.length; i++) { for (let i = 0; i < chordWords.length; i++) {
@@ -711,13 +1120,16 @@ export class ProposeCantoPage implements OnInit {
if (!part.trim()) return part; if (!part.trim()) return part;
let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, ''); let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean); clean = this.sanitizeOcrChord(clean);
return `[${this.convertEnglishChordToItalian(clean)}]`; if (this.isChordWord(part, isItalian)) {
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
} else {
return part;
}
}).join(''); }).join('');
}).join(' '); }).join(' ');
} }
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i; const multiChordRegex = this.getMultiChordRegex(isItalian);
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
const expandedChordWords: any[] = []; const expandedChordWords: any[] = [];
mergedChordWords.forEach(chord => { mergedChordWords.forEach(chord => {
@@ -822,7 +1234,11 @@ export class ProposeCantoPage implements OnInit {
let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase(); let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
cleanChord = this.sanitizeOcrChord(cleanChord); cleanChord = this.sanitizeOcrChord(cleanChord);
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord)}]`; if (this.isChordWord(chord.text, isItalian)) {
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord, isItalian)}]`;
} else {
wordResult += wordText.substring(lastCharIndex, charIndex);
}
lastCharIndex = charIndex; lastCharIndex = charIndex;
}); });
@@ -851,7 +1267,12 @@ export class ProposeCantoPage implements OnInit {
smartProcessOCR(text: string): string { smartProcessOCR(text: string): string {
let lines = text.split('\n'); let lines = text.split('\n');
let processedLines: string[] = []; let processedLines: string[] = [];
const chordRegex = /\b(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?\b/gi;
const words = text.split(/\s+/).map(t => ({ text: t }));
const isItalian = this.isItalianNotation(words);
const chordRegex = isItalian
? /(DO|RE|MI|FA|SOL|LA|SI)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?/i
: /(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?/i;
let inChorus = false; let inChorus = false;
let inVerse = false; let inVerse = false;
@@ -869,13 +1290,13 @@ export class ProposeCantoPage implements OnInit {
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; } if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
processedLines.push('{start_chorus}'); processedLines.push('{start_chorus}');
inChorus = true; inChorus = true;
if (trimmed.length > 12) processedLines.push(this.wrapChords(trimmed, chordRegex)); if (trimmed.length > 12) processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
} else { } else {
if (!inVerse && !inChorus && trimmed.length > 5) { if (!inVerse && !inChorus && trimmed.length > 5) {
processedLines.push('{start_verse}'); processedLines.push('{start_verse}');
inVerse = true; inVerse = true;
} }
processedLines.push(this.wrapChords(trimmed, chordRegex)); processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
} }
}); });
@@ -884,21 +1305,24 @@ export class ProposeCantoPage implements OnInit {
return processedLines.join('\n'); return processedLines.join('\n');
} }
private wrapChords(line: string, regex: RegExp): string { private wrapChords(line: string, regex: RegExp, isItalian: boolean): string {
const globalRegex = new RegExp(regex.source.replace(/^\^/, '').replace(/\$$/, ''), 'gi'); let source = regex.source.replace(/^\^/, '').replace(/\$$/, '');
if (!source.startsWith('\\b')) source = '\\b' + source;
if (!source.endsWith('\\b')) source = source + '\\b';
const globalRegex = new RegExp(source, 'gi');
const parts = line.split(/(_)/); const parts = line.split(/(_)/);
return parts.map((part: string) => { return parts.map((part: string) => {
if (part === '_') return ' _ '; if (part === '_') return ' _ ';
return part.replace(globalRegex, (match) => { return part.replace(globalRegex, (match) => {
const lower = match.toLowerCase(); const lower = match.toLowerCase();
if (match === 'a' || match === 'e' || match === 'o' || match === 'i') { if (['a', 'e', 'o', 'i'].includes(lower)) {
return match; return match;
} }
if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower) && match === lower) { if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower)) {
return match; return match;
} }
const sanitized = this.sanitizeOcrChord(match.toUpperCase()); const sanitized = this.sanitizeOcrChord(match.toUpperCase());
return `[${this.convertEnglishChordToItalian(sanitized)}]`; return `[${this.convertEnglishChordToItalian(sanitized, isItalian)}]`;
}); });
}).join(''); }).join('');
} }
@@ -908,6 +1332,15 @@ export class ProposeCantoPage implements OnInit {
async saveToMyCanti() { async saveToMyCanti() {
if (!this.title || !this.content) return; if (!this.title || !this.content) return;
// Automatically convert any [LAM]/[LAN], [REM]/[REN] etc. to [LAm], [REm] in content before saving
this.content = this.content.replace(/\[(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\]/g, (match, p1, p2, p3) => {
return `[${p1}${p2 || ''}m${p3 || ''}]`;
});
await this.proceedSaveToMyCanti();
}
private async proceedSaveToMyCanti() {
// Combine lit and tematico for id_momenti // Combine lit and tematico for id_momenti
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico]; const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
+15 -1
View File
@@ -53,6 +53,20 @@
</div> </div>
</div> </div>
<!-- PWA Install Group (Desktop Fallback Instructions) -->
<div class="settings-group glass ion-margin-bottom" *ngIf="!settingsService.showInstallButton() && !settingsService.isStandalone() && !settingsService.isIos()">
<ion-item class="transparent-item" lines="none">
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font" style="white-space: normal;">
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold; margin-bottom: 6px;">Come installare l'applicazione</h2>
<p class="settings-item-subtitle" style="font-size: 0.85rem; line-height: 1.45; opacity: 0.9; color: var(--ion-text-color); margin: 0;">
Se usi <strong>Chrome / Edge / Brave</strong>: puoi installarla cliccando sull'icona di installazione <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;"></span> o <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">📥</span> che appare a destra nella barra degli indirizzi del browser.<br><br>
Se usi <strong>Safari (su Mac)</strong>: clicca sul menu <strong>File</strong> in alto e seleziona <strong>Aggiungi al Dock...</strong>.
</p>
</ion-label>
</ion-item>
</div>
<div class="settings-group glass ion-margin-bottom"> <div class="settings-group glass ion-margin-bottom">
<ion-item class="transparent-item" lines="none"> <ion-item class="transparent-item" lines="none">
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon> <ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
@@ -175,7 +189,7 @@
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;"> <div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
<input type="text" <input type="text"
#comunitaCodeInput #comunitaCodeInput
placeholder="Es: 123456" placeholder="il codice di 6 cifre"
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;" style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
class="outfit-font" class="outfit-font"
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)"> (keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
+7 -2
View File
@@ -48,7 +48,12 @@ export class CantiService {
public progress = signal<number>(0); public progress = signal<number>(0);
public firstLoadCompleted = signal<boolean>(false); public firstLoadCompleted = signal<boolean>(false);
private API_URL = 'https://www.canticristiani.it/api/canti.json'; private getApiUrl(): string {
const isProduction = window.location.hostname.includes('canticristiani.it');
return isProduction
? `${window.location.origin}/api/canti.json`
: 'https://www.canticristiani.it/api/canti.json';
}
constructor() { constructor() {
this.init(); this.init();
@@ -92,7 +97,7 @@ export class CantiService {
this.loading.set(true); this.loading.set(true);
this.progress.set(0); this.progress.set(0);
this.http.get(`${this.API_URL}?t=${Date.now()}`, { this.http.get(`${this.getApiUrl()}?t=${Date.now()}`, {
reportProgress: true, reportProgress: true,
observe: 'events' observe: 'events'
}).subscribe({ }).subscribe({
@@ -101,4 +101,29 @@ Rallegri*amoci,*
expect(sections[1].lines[0].text).toBe('Rallegriamoci,'); expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
expect(sections[1].lines[0].segments[0].chord).toBeUndefined(); expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
}); });
it('should format sections outside of soc/eoc tags as verses', () => {
const rawSong = `
{c:Intro:} [RE]
[RE]Guardami Signor
{soc}
Abba Padre!
{eoc}
[RE]Più solo non sarò
`;
const sections = service.parseAccordi(rawSong);
// There should be three sections:
// 1. Verse (Intro & Guardami Signor)
// 2. Chorus (Abba Padre)
// 3. Verse (Più solo non sarò)
expect(sections.length).toBe(3);
expect(sections[0].type).toBe('verse');
expect(sections[0].lines[0].text).toBe('Intro ');
expect(sections[0].lines[0].segments[0].text).toBe('Intro ');
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
expect(sections[0].lines[0].segments[1].chord).toBe('RE');
expect(sections[0].lines[1].text).toBe('Guardami Signor');
expect(sections[1].type).toBe('chorus');
expect(sections[2].type).toBe('verse');
});
}); });
+12 -1
View File
@@ -116,11 +116,12 @@ export class LyricsParserService {
currentLines = []; currentLines = [];
currentRawLines = []; currentRawLines = [];
currentAction = null; currentAction = null;
currentType = 'verse';
continue; continue;
} }
// Skip structural tags (already handled above) // Skip structural tags (already handled above)
if (trimmed.startsWith('{') && trimmed.endsWith('}')) { if (trimmed.startsWith('{') && trimmed.endsWith('}') && !trimmed.startsWith('{c:') && !trimmed.startsWith('{comment:')) {
continue; continue;
} }
@@ -148,6 +149,11 @@ export class LyricsParserService {
} }
} }
// Clean comment tags: {c:Text} or {comment:Text} -> Text (removing trailing colon if any)
resolvedLine = resolvedLine.replace(/\{(?:c|comment):([^}]+)\}/g, (match, p1) => {
return p1.trim().replace(/:$/, '');
});
currentRawLines.push(line); currentRawLines.push(line);
// Parse line // Parse line
@@ -246,6 +252,11 @@ export class LyricsParserService {
transposeChord(chord: string, semitones: number): string { transposeChord(chord: string, semitones: number): string {
if (!chord) return chord; if (!chord) return chord;
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
chord = chord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
return p1 + (p2 || '') + 'm' + (p3 || '');
});
// Handle slash chords (e.g., DO/SOL) // Handle slash chords (e.g., DO/SOL)
if (chord.includes('/')) { if (chord.includes('/')) {
return chord.split('/') return chord.split('/')
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { CantiService } from './canti.service'; import { CantiService } from './canti.service';
import { MyCantiService } from './my-canti.service'; import { MyCantiService } from './my-canti.service';
import { ComunitaService } from './comunita.service'; import { ComunitaService } from './comunita.service';
import { PlaylistService } from './playlist.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -10,6 +11,7 @@ export class MediaSessionService {
private cantiService = inject(CantiService); private cantiService = inject(CantiService);
private myCantiService = inject(MyCantiService); private myCantiService = inject(MyCantiService);
private comunitaService = inject(ComunitaService); private comunitaService = inject(ComunitaService);
private playlistService = inject(PlaylistService);
public updateMetadata(cantoId: string) { public updateMetadata(cantoId: string) {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
@@ -21,6 +23,12 @@ export class MediaSessionService {
if (!canto) { if (!canto) {
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId); canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
} }
if (!canto) {
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
}
if (!canto) {
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
}
if (!canto) return; if (!canto) return;
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png'; const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
+57 -18
View File
@@ -386,6 +386,34 @@ export class PlaylistService {
}); });
}); });
this.remoteCustomSongs().forEach(c => {
const id_canti = c.id_canti || Date.now();
mergedSongsMap.set(id_canti, {
id_canti,
titolo: c.titolo || 'Senza Titolo',
autore: c.autore || '',
link_youtube: c.link_youtube || '',
accordi: c.accordi || '',
momenti: c.id_momenti?.map((id: any) => String(id)) || [],
periodi: [] as string[],
testo: c.testo || ''
});
});
this.comunitaService.comunitaCantiPersonali().forEach(c => {
const id_canti = c.id_canti || Date.now();
mergedSongsMap.set(id_canti, {
id_canti,
titolo: c.titolo || 'Senza Titolo',
autore: c.autore || '',
link_youtube: c.link_youtube || '',
accordi: c.accordi || '',
momenti: c.id_momenti?.map((id: any) => String(id)) || [],
periodi: [] as string[],
testo: c.testo || ''
});
});
const customSongs = Array.from(mergedSongsMap.values()); const customSongs = Array.from(mergedSongsMap.values());
const playlistSongs = this.playlists().map(pl => ({ const playlistSongs = this.playlists().map(pl => ({
@@ -570,32 +598,43 @@ export class PlaylistService {
const blob = await res.blob(); const blob = await res.blob();
const file = new File([blob], fileName, { type: 'image/png' }); const file = new File([blob], fileName, { type: 'image/png' });
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ await navigator.share({
files: [file], files: [file],
title: 'Playlist CantiCristiani', title: 'Playlist CantiCristiani',
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}` text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
}); });
} else { } else {
// Fallback: copia il link negli appunti e scarica l'immagine del QR // Fallback to simple share text or copy/download
try { if (navigator.share) {
if (navigator.clipboard) { await navigator.share({
await navigator.clipboard.writeText(shareLink); title: 'Playlist CantiCristiani',
const toast = await this.toastCtrl.create({ text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
message: 'Link playlist copiato negli appunti! QR Code scaricato.', });
duration: 3000, } else {
color: 'success' // Fallback: copia il link negli appunti e scarica l'immagine del QR
}); try {
await toast.present(); if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink);
const toast = await this.toastCtrl.create({
message: 'Link playlist copiato negli appunti! QR Code scaricato.',
duration: 3000,
color: 'success'
});
await toast.present();
}
} catch (clipErr) {
console.warn('Failed to copy link to clipboard:', clipErr);
} }
} catch (clipErr) {
console.warn('Failed to copy link to clipboard:', clipErr);
}
const link = document.createElement('a'); const link = document.createElement('a');
link.href = qrImage; link.href = qrImage;
link.download = fileName; link.download = fileName;
link.click(); link.click();
}
} }
} catch (err) { } catch (err) {
console.error('Share failed', err); console.error('Share failed', err);
+5 -4
View File
@@ -54,7 +54,7 @@ export class SettingsService {
public karaokePageScrollMode = signal<boolean>(false); public karaokePageScrollMode = signal<boolean>(false);
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */ /** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
public landscapeProjectionEnabled = signal<boolean>(true); public landscapeProjectionEnabled = signal<boolean>(false);
/** Identificativo utente univoco per la gestione delle comunità */ /** Identificativo utente univoco per la gestione delle comunità */
public userUuid = signal<string>(''); public userUuid = signal<string>('');
@@ -251,7 +251,7 @@ export class SettingsService {
if (savedLandscapeProjectionEnabled !== null) { if (savedLandscapeProjectionEnabled !== null) {
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true'); this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
} else { } else {
this.landscapeProjectionEnabled.set(true); this.landscapeProjectionEnabled.set(false);
} }
// Sync browser fullscreen state with listeners (supporting vendor prefixes) // Sync browser fullscreen state with listeners (supporting vendor prefixes)
@@ -464,10 +464,10 @@ export class SettingsService {
localStorage.setItem('user-name', trimmed); localStorage.setItem('user-name', trimmed);
} }
async installPwa() { async installPwa(): Promise<string | undefined> {
const promptEvent = this.deferredPrompt(); const promptEvent = this.deferredPrompt();
if (!promptEvent) { if (!promptEvent) {
return; return undefined;
} }
// Show the install prompt // Show the install prompt
promptEvent.prompt(); promptEvent.prompt();
@@ -477,5 +477,6 @@ export class SettingsService {
// We've used the prompt, and can't use it again, discard it // We've used the prompt, and can't use it again, discard it
this.deferredPrompt.set(null); this.deferredPrompt.set(null);
this.showInstallButton.set(false); this.showInstallButton.set(false);
return outcome;
} }
} }
@@ -5,6 +5,7 @@ import { MediaSessionService } from './media-session.service';
import { MyCantiService } from './my-canti.service'; import { MyCantiService } from './my-canti.service';
import { ConnectivityService } from './connectivity.service'; import { ConnectivityService } from './connectivity.service';
import { ComunitaService } from './comunita.service'; import { ComunitaService } from './comunita.service';
import { PlaylistService } from './playlist.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -16,6 +17,7 @@ export class YoutubePlayerService {
private myCantiService = inject(MyCantiService); private myCantiService = inject(MyCantiService);
private connectivityService = inject(ConnectivityService); private connectivityService = inject(ConnectivityService);
private comunitaService = inject(ComunitaService); private comunitaService = inject(ComunitaService);
private playlistService = inject(PlaylistService);
public isPlayerSupported = computed<boolean>(() => { public isPlayerSupported = computed<boolean>(() => {
// Check if offline // Check if offline
@@ -127,6 +129,12 @@ export class YoutubePlayerService {
if (!canto) { if (!canto) {
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId); canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
} }
if (!canto) {
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
}
if (!canto) {
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
}
if (!canto) return; if (!canto) return;
const videoId = this.cantiService.getYoutubeId(canto.link_youtube); const videoId = this.cantiService.getYoutubeId(canto.link_youtube);
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.06.17.0049'; export const VERSION = '2026.06.18.0234';
+31
View File
File diff suppressed because one or more lines are too long