feat: allinea lo swipe e lo scroll con l'avanzamento karaoke, aggiungi frecce di navigazione canti nel titolo
This commit is contained in:
+1
-64
@@ -52,67 +52,4 @@ if [ ! -d "www" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Upload via FTP ---
|
# --- Upload via FTP ---
|
||||||
echo "🚀 2/2 Caricamento parallelo ($THREADS connessioni) su $FTP_HOST nella ROOT..."
|
python3 scratch/deploy_ftp.py
|
||||||
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)
|
|
||||||
(
|
|
||||||
# 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_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 nella ROOT senza errori!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
rm "$PROGRESS_LOG" "$ERROR_LOG"
|
|
||||||
printf "L'app è disponibile su https://www.canticristiani.it/\n"
|
|
||||||
|
|||||||
@@ -8,3 +8,12 @@
|
|||||||
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
|
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
|
||||||
RewriteRule ^ index.html [L]
|
RewriteRule ^ index.html [L]
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
# Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker
|
||||||
|
<FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest">
|
||||||
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
Header set Pragma "no-cache"
|
||||||
|
Header set Expires 0
|
||||||
|
</FilesMatch>
|
||||||
|
</IfModule>
|
||||||
|
|||||||
+90
-41
@@ -19,68 +19,117 @@ export class AppComponent {
|
|||||||
this.setupUpdates();
|
this.setupUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async forceBypassCacheAndCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
for (const registration of registrations) {
|
||||||
|
await registration.update();
|
||||||
|
console.log('[PWA-Update] Native Service Worker updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali
|
||||||
|
await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||||
|
await fetch('/ngsw.json', { cache: 'reload' });
|
||||||
|
console.log('[PWA-Update] Caches successfully busted for ngsw.json');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e);
|
||||||
|
}
|
||||||
|
return await this.swUpdate.checkForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
private setupUpdates() {
|
private setupUpdates() {
|
||||||
if (this.swUpdate.isEnabled) {
|
if (this.swUpdate.isEnabled) {
|
||||||
// Wait for the application to stabilize before running update checks or starting intervals
|
const launchTime = Date.now();
|
||||||
|
|
||||||
|
// Ricarica automaticamente se il Service Worker controller cambia per garantire la freschezza immediata
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
console.log('[PWA-Update] Controller changed. Reloading page...');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Sottoscrizione all'evento di versione scaricata/pronta
|
||||||
|
this.swUpdate.versionUpdates
|
||||||
|
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const timeSinceLaunch = Date.now() - launchTime;
|
||||||
|
console.log(`[PWA-Update] New version ready! Time since launch: ${timeSinceLaunch}ms`);
|
||||||
|
|
||||||
|
if (timeSinceLaunch < 8000) {
|
||||||
|
// Se l'applicazione è appena stata aperta (< 8s), la aggiorniamo ed eseguiamo il reload immediato e silenzioso
|
||||||
|
console.log('[PWA-Update] Auto-activating update on startup...');
|
||||||
|
try {
|
||||||
|
await this.swUpdate.activateUpdate();
|
||||||
|
console.log('[PWA-Update] Update activated successfully, reloading page...');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PWA-Update] Auto-activation failed on startup:', err);
|
||||||
|
// Fallback: ricarica comunque per provare a forzare l'attivazione
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Altrimenti mostriamo il prompt interattivo per evitare interruzioni improvvise
|
||||||
|
console.log('[PWA-Update] Showing toast prompt for active user...');
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'Nuova versione dell\'applicazione disponibile!',
|
||||||
|
position: 'bottom',
|
||||||
|
color: 'secondary',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Aggiorna',
|
||||||
|
role: 'cancel',
|
||||||
|
handler: () => {
|
||||||
|
console.log('[PWA-Update] Activating update and reloading...');
|
||||||
|
this.swUpdate.activateUpdate().then(() => {
|
||||||
|
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Controllo IMMEDIATO all'avvio dell'applicazione (senza attendere lo stato isStable)
|
||||||
|
console.log('[PWA-Update] Startup: Checking for PWA updates immediately...');
|
||||||
|
this.forceBypassCacheAndCheck().catch(err => {
|
||||||
|
console.warn('[PWA-Update] Startup update check failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Attiva i controlli periodici in background solo DOPO la stabilizzazione dell'app
|
||||||
this.appRef.isStable.pipe(
|
this.appRef.isStable.pipe(
|
||||||
filter(stable => stable),
|
filter(stable => stable),
|
||||||
first()
|
first()
|
||||||
).subscribe(() => {
|
).subscribe(() => {
|
||||||
console.log('[PWA-Update] App is stable. Initializing update checks...');
|
console.log('[PWA-Update] App is stable. Initializing background updates...');
|
||||||
|
|
||||||
// 1. Check for updates immediately
|
// Controllo periodico ogni 60 secondi
|
||||||
this.swUpdate.checkForUpdate().catch(err => {
|
const every60Seconds$ = interval(60 * 1000);
|
||||||
console.warn('[PWA-Update] Failed immediate startup update check:', err);
|
every60Seconds$.subscribe(async () => {
|
||||||
});
|
console.log('[PWA-Update] Periodic check for updates (every 60s)...');
|
||||||
|
|
||||||
// 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.forceBypassCacheAndCheck();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[PWA-Update] Failed periodic update check:', err);
|
console.warn('[PWA-Update] Periodic update check failed:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Check for updates when the app is resumed/focused
|
// 4. Controllo quando l'utente torna sulla scheda o ripristina l'app (Visibility Change)
|
||||||
fromEvent(document, 'visibilitychange')
|
fromEvent(document, 'visibilitychange')
|
||||||
.pipe(filter(() => document.visibilityState === 'visible'))
|
.pipe(filter(() => document.visibilityState === 'visible'))
|
||||||
.subscribe(async () => {
|
.subscribe(async () => {
|
||||||
console.log('[PWA-Update] App resumed, checking for PWA updates...');
|
console.log('[PWA-Update] App resumed, checking for PWA updates...');
|
||||||
try {
|
try {
|
||||||
await this.swUpdate.checkForUpdate();
|
await this.forceBypassCacheAndCheck();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[PWA-Update] Failed visible resume update check:', err);
|
console.warn('[PWA-Update] Resume update check failed:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Activate update and reload when a new version is ready (Show interactive toast)
|
|
||||||
this.swUpdate.versionUpdates
|
|
||||||
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
|
||||||
.subscribe(async () => {
|
|
||||||
console.log('[PWA-Update] New version ready! Showing toast prompt...');
|
|
||||||
const toast = await this.toastCtrl.create({
|
|
||||||
message: 'Nuova versione dell\'applicazione disponibile!',
|
|
||||||
position: 'bottom',
|
|
||||||
color: 'secondary',
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
text: 'Aggiorna',
|
|
||||||
role: 'cancel',
|
|
||||||
handler: () => {
|
|
||||||
console.log('[PWA-Update] Activating update and reloading...');
|
|
||||||
this.swUpdate.activateUpdate().then(() => {
|
|
||||||
window.location.replace(window.location.origin + window.location.pathname + '?update=' + Date.now());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
await toast.present();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
<span class="canto-number" *ngIf="canto()?.id_canti">
|
<span class="canto-number" *ngIf="canto()?.id_canti">
|
||||||
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }}
|
{{ canto()?.id?.startsWith('my_') ? 'M' : canto()?.id_canti }}
|
||||||
</span>
|
</span>
|
||||||
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap;">
|
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||||
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||||
<span>{{ canto()?.titolo || 'Player' }}</span>
|
<span>{{ canto()?.titolo || 'Player' }}</span>
|
||||||
|
<span style="display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;">
|
||||||
|
<ion-button fill="clear" (click)="prevSong()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px; width: 28px; min-height: 28px;">
|
||||||
|
<ion-icon name="chevron-back" color="secondary" style="font-size: 1.1rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="nextSong()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px; width: 28px; min-height: 28px;">
|
||||||
|
<ion-icon name="chevron-forward" color="secondary" style="font-size: 1.1rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ion-title>
|
</ion-title>
|
||||||
@@ -37,7 +45,8 @@
|
|||||||
[class.full-screen-container]="settingsService.fullscreenMode()"
|
[class.full-screen-container]="settingsService.fullscreenMode()"
|
||||||
(touchstart)="onTouchStart($event)"
|
(touchstart)="onTouchStart($event)"
|
||||||
(touchmove)="onTouchMove($event)"
|
(touchmove)="onTouchMove($event)"
|
||||||
(touchend)="onTouchEnd()">
|
(touchend)="onTouchEnd()"
|
||||||
|
(wheel)="onWheel($event)">
|
||||||
|
|
||||||
|
|
||||||
<!-- Landscape Side Controls (Scrollable) -->
|
<!-- Landscape Side Controls (Scrollable) -->
|
||||||
@@ -88,6 +97,8 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima allo schermo -->
|
||||||
|
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -126,9 +137,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
|
||||||
<!-- Voice Activity Visualizer (Slim overlay) -->
|
<!-- Visualizzatore Vocale & Log del Riconoscimento in Tempo Reale -->
|
||||||
<div class="transcript-area slim" *ngIf="settingsService.enableAcousticAutoscroll() && audioEngine.isListening()">
|
<div class="transcript-area" *ngIf="settingsService.enableAcousticAutoscroll() && audioEngine.isListening()" style="background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); padding: 10px 14px; border-top: 1px solid rgba(255,255,255,0.08); display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; position: relative;">
|
||||||
<div class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)"></div>
|
<div class="energy-bar-container" style="width: 100%; height: 5px; background: rgba(255,255,255,0.12); border-radius: 3px; overflow: hidden;">
|
||||||
|
<div class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)" style="height: 100%; background: linear-gradient(90deg, var(--ion-color-secondary) 0%, #20e9ff 100%); border-radius: 3px; transition: width 0.08s ease-out;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="speech-log-row" style="display: flex; align-items: center; justify-content: center; width: 100%;">
|
||||||
|
<div class="acoustic-counter-badge" style="display: inline-flex; align-items: center; gap: 6px; background: rgba(32, 233, 255, 0.12); border: 1px solid rgba(32, 233, 255, 0.25); color: #20e9ff; padding: 4px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 700;">
|
||||||
|
<span style="width: 6px; height: 6px; background: #20e9ff; border-radius: 50%; display: inline-block; box-shadow: 0 0 8px #20e9ff; animation: pulse 1.5s infinite;"></span>
|
||||||
|
<span>Parole Rilevate: {{ wordsSpokenInCurrentPage }} / {{ getPageTargetWordCount() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ion-toolbar class="bg-gradient slim-toolbar">
|
<ion-toolbar class="bg-gradient slim-toolbar">
|
||||||
|
|||||||
@@ -258,9 +258,7 @@
|
|||||||
|
|
||||||
// Transcript area
|
// Transcript area
|
||||||
.transcript-area {
|
.transcript-area {
|
||||||
height: 4px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
|
|
||||||
.energy-bar {
|
.energy-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -270,6 +268,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(0.9); opacity: 0.6; }
|
||||||
|
50% { transform: scale(1.15); opacity: 1; }
|
||||||
|
100% { transform: scale(0.9); opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
// Fullscreen and Orientation
|
// Fullscreen and Orientation
|
||||||
@media (orientation: landscape) {
|
@media (orientation: landscape) {
|
||||||
// Always hide footer in landscape as we have side controls
|
// Always hide footer in landscape as we have side controls
|
||||||
@@ -560,3 +564,51 @@ ion-content.full-screen-content {
|
|||||||
padding-inline-start: 8px;
|
padding-inline-start: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.autoscroll-sync-status {
|
||||||
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(212, 136, 0, 0.18) !important;
|
||||||
|
border: 1px solid rgba(253, 203, 110, 0.5);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
z-index: 999;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
animation: slideDownSync 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fdcb6e;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-animation {
|
||||||
|
animation: spinSync 2s linear infinite;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinSync {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDownSync {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
public youtubePlayerService = inject(YoutubePlayerService);
|
public youtubePlayerService = inject(YoutubePlayerService);
|
||||||
private channel = new BroadcastChannel('karaoke_sync');
|
private channel = new BroadcastChannel('karaoke_sync');
|
||||||
|
|
||||||
|
// Rimossi stati di freeze fragili per garantire fluidità e stabilità 100% dell'avanzamento
|
||||||
|
|
||||||
private readonly MIN_FONT = 0.6;
|
private readonly MIN_FONT = 0.6;
|
||||||
private readonly MAX_FONT = 5.0;
|
private readonly MAX_FONT = 5.0;
|
||||||
private readonly FONT_STEP = 0.15;
|
private readonly FONT_STEP = 0.15;
|
||||||
@@ -96,7 +98,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private lastAdvanceTimestamp: number = 0; // For safety cooldown
|
private lastAdvanceTimestamp: number = 0; // For safety cooldown
|
||||||
private readonly ADVANCE_COOLDOWN = 1500; // 1.5 seconds min
|
private readonly ADVANCE_COOLDOWN = 1500; // 1.5 seconds min
|
||||||
|
|
||||||
private wordsSpokenInCurrentLine: number = 0;
|
public wordsSpokenInCurrentPage: number = 0;
|
||||||
|
public lastScrollBlock = signal<'start' | 'center'>('start');
|
||||||
private lastProcessedTranscript: string = '';
|
private lastProcessedTranscript: string = '';
|
||||||
private initialStartTime: number = 0;
|
private initialStartTime: number = 0;
|
||||||
|
|
||||||
@@ -132,11 +135,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.showChords.set(this.settingsService.showChordsDefault());
|
this.showChords.set(this.settingsService.showChordsDefault());
|
||||||
}, { allowSignalWrites: true });
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
// Automatic advancement logic based on line detection
|
// Avanzamento guidato dai picchi acustici con conteggio parole della riga in corso
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const count = this.audioEngine.linesDetected();
|
const count = this.audioEngine.linesDetected();
|
||||||
if (count > 0) {
|
if (count > 0 && this.audioEngine.isListening()) {
|
||||||
this.next();
|
this.handleAcousticPeak();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avanzamento in tempo reale tramite riconoscimento vocale di prossimità (driver primario veloce)
|
||||||
|
effect(() => {
|
||||||
|
const transcript = this.audioEngine.backgroundTranscript();
|
||||||
|
const isSpeechActive = this.audioEngine.speechRecognitionActive();
|
||||||
|
if (transcript && this.audioEngine.isListening() && isSpeechActive) {
|
||||||
|
this.checkWordAdvancement();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,7 +159,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const activeElem = document.querySelector('.lyric-line.active');
|
const activeElem = document.querySelector('.lyric-line.active');
|
||||||
if (activeElem) {
|
if (activeElem) {
|
||||||
activeElem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
// Usa l'allineamento calcolato dinamico (start per manuale a pagine, center per automatico e standard)
|
||||||
|
// Usiamo behavior: 'auto' poiché il container CSS ha già 'scroll-behavior: smooth', evitando blocchi/conflitti nativi Chromium
|
||||||
|
const scrollBlock = this.lastScrollBlock();
|
||||||
|
activeElem.scrollIntoView({ behavior: 'auto', block: scrollBlock });
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
@@ -241,26 +256,45 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
const gesture = this.gestureCtrl.create({
|
const gestureX = this.gestureCtrl.create({
|
||||||
el: this.el.nativeElement,
|
el: this.el.nativeElement,
|
||||||
direction: 'x',
|
direction: 'x',
|
||||||
gestureName: 'swipe-song',
|
gestureName: 'swipe-song-x',
|
||||||
canStart: (ev) => {
|
canStart: (ev) => {
|
||||||
// Prevent swipe when touching the bottom toolbar or other interactive elements
|
|
||||||
const target = ev.event.target as HTMLElement;
|
const target = ev.event.target as HTMLElement;
|
||||||
return !target.closest('ion-footer');
|
return !target.closest('ion-footer') && !target.closest('ion-header');
|
||||||
},
|
},
|
||||||
onEnd: (ev) => {
|
onEnd: (ev) => {
|
||||||
if (Math.abs(ev.deltaX) > 60) {
|
if (Math.abs(ev.deltaX) > 60) {
|
||||||
if (ev.deltaX > 0) {
|
if (ev.deltaX > 0) {
|
||||||
this.prevSong();
|
this.prev();
|
||||||
} else {
|
} else {
|
||||||
this.nextSong();
|
this.next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
gesture.enable();
|
gestureX.enable();
|
||||||
|
|
||||||
|
const gestureY = this.gestureCtrl.create({
|
||||||
|
el: this.el.nativeElement,
|
||||||
|
direction: 'y',
|
||||||
|
gestureName: 'swipe-song-y',
|
||||||
|
canStart: (ev) => {
|
||||||
|
const target = ev.event.target as HTMLElement;
|
||||||
|
return !target.closest('ion-footer') && !target.closest('ion-header');
|
||||||
|
},
|
||||||
|
onEnd: (ev) => {
|
||||||
|
if (Math.abs(ev.deltaY) > 50) {
|
||||||
|
if (ev.deltaY > 0) {
|
||||||
|
this.prev();
|
||||||
|
} else {
|
||||||
|
this.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
gestureY.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -327,6 +361,25 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.initialPinchDistance = null;
|
this.initialPinchDistance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lastWheelTime = 0;
|
||||||
|
private readonly WHEEL_COOLDOWN = 400; // ms to avoid too rapid advancement
|
||||||
|
|
||||||
|
onWheel(event: WheelEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastWheelTime < this.WHEEL_COOLDOWN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(event.deltaY) > 5) {
|
||||||
|
if (event.deltaY > 0) {
|
||||||
|
this.next();
|
||||||
|
} else {
|
||||||
|
this.prev();
|
||||||
|
}
|
||||||
|
this.lastWheelTime = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getDistance(t1: Touch, t2: Touch): number {
|
private getDistance(t1: Touch, t2: Touch): number {
|
||||||
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
|
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
|
||||||
}
|
}
|
||||||
@@ -442,25 +495,356 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.audioEngine.resetWordCount();
|
this.audioEngine.resetWordCount();
|
||||||
this.youtubePlayerService.seekTo(0);
|
this.youtubePlayerService.seekTo(0);
|
||||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
|
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
|
||||||
|
this.wordsSpokenInCurrentPage = 0;
|
||||||
|
this.lastScrollBlock.set('center');
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
public calculatePageStepSize(): number {
|
||||||
this.lastAdvanceTimestamp = Date.now();
|
try {
|
||||||
this.audioEngine.resetWordCount();
|
const container = document.querySelector('.lyrics-container') as HTMLElement;
|
||||||
|
if (!container) return 3; // Ritorno generico se il contenitore non è pronto
|
||||||
|
|
||||||
const totalLines = this.getTotalLines();
|
const containerHeight = container.clientHeight;
|
||||||
if (this.currentLineIndex() < totalLines - 1) {
|
|
||||||
this.currentLineIndex.set(this.currentLineIndex() + 1);
|
// Calcoliamo la reale altezza visibile sottraendo l'eventuale footer in sovraimpressione
|
||||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: this.currentLineIndex() });
|
let visibleHeight = containerHeight;
|
||||||
|
const footer = document.querySelector('ion-footer') as HTMLElement;
|
||||||
|
if (footer && footer.offsetHeight > 0) {
|
||||||
|
const computedStyle = window.getComputedStyle(footer);
|
||||||
|
if (computedStyle.display !== 'none') {
|
||||||
|
visibleHeight -= footer.offsetHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineElems = document.querySelectorAll('.lyric-line');
|
||||||
|
|
||||||
|
if (lineElems.length === 0) return 3;
|
||||||
|
|
||||||
|
let totalHeight = 0;
|
||||||
|
lineElems.forEach((el: any) => {
|
||||||
|
totalHeight += el.clientHeight;
|
||||||
|
});
|
||||||
|
const avgLineHeight = totalHeight / lineElems.length;
|
||||||
|
|
||||||
|
if (avgLineHeight <= 0) return 3;
|
||||||
|
|
||||||
|
// Quante righe entrano effettivamente nella vista REALE non oscurata dello schermo
|
||||||
|
const linesPerPage = Math.floor(visibleHeight / avgLineHeight);
|
||||||
|
|
||||||
|
// Passo di scorrimento: una pagina intera pulita (zero sovrapposizione) per far sparire il testo precedente
|
||||||
|
const pageStep = Math.max(1, linesPerPage);
|
||||||
|
|
||||||
|
console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', {
|
||||||
|
containerHeight,
|
||||||
|
visibleHeight,
|
||||||
|
avgLineHeight,
|
||||||
|
linesPerPage,
|
||||||
|
pageStep
|
||||||
|
});
|
||||||
|
|
||||||
|
return pageStep;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PageScroll] Impossibile calcolare dinamicamente lo step di pagina:', e);
|
||||||
|
return 3; // Fallback di emergenza
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLineWords(lineIndex: number): string[] {
|
||||||
|
const allLines = this.getAllLines();
|
||||||
|
const lineText = allLines[lineIndex]?.text || '';
|
||||||
|
return lineText
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w: string) => w.replace(/[^a-zA-Z0-9àèìòùáéíóú]/g, ''))
|
||||||
|
.filter((w: string) => w.length >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPageWords(startIndex: number, pageSize: number): string[] {
|
||||||
|
const allWords: string[] = [];
|
||||||
|
const totalLines = this.getTotalLines();
|
||||||
|
const end = Math.min(totalLines, startIndex + pageSize);
|
||||||
|
for (let i = startIndex; i < end; i++) {
|
||||||
|
allWords.push(...this.getLineWords(i));
|
||||||
|
}
|
||||||
|
return allWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVisibleLineIndices(): number[] {
|
||||||
|
try {
|
||||||
|
const container = document.querySelector('.lyrics-container') as HTMLElement;
|
||||||
|
if (!container) return [];
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const visibleTop = containerRect.top;
|
||||||
|
let visibleBottom = containerRect.bottom;
|
||||||
|
|
||||||
|
// Sottrai l'altezza dell'eventuale footer sovrapposto ad alto z-index
|
||||||
|
const footer = document.querySelector('ion-footer') as HTMLElement;
|
||||||
|
if (footer) {
|
||||||
|
const footerRect = footer.getBoundingClientRect();
|
||||||
|
if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') {
|
||||||
|
visibleBottom = Math.min(visibleBottom, footerRect.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineElems = document.querySelectorAll('.lyric-line');
|
||||||
|
const visibleIndices: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lineElems.length; i++) {
|
||||||
|
const el = lineElems[i] as HTMLElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Consideriamo la riga visibile se la sua metà verticale è all'interno dei limiti visibili reali
|
||||||
|
const lineMiddle = (rect.top + rect.bottom) / 2;
|
||||||
|
if (lineMiddle >= visibleTop && lineMiddle <= visibleBottom) {
|
||||||
|
visibleIndices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visibleIndices;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PageScroll] Errore nel recupero degli indici delle righe visibili:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPageTargetWordCount(): number {
|
||||||
|
const visibleIndices = this.getVisibleLineIndices();
|
||||||
|
let totalRawWords = 0;
|
||||||
|
const currentIdx = this.currentLineIndex();
|
||||||
|
|
||||||
|
if (visibleIndices.length > 0) {
|
||||||
|
if (currentIdx === 0) {
|
||||||
|
// Sulla prima pagina, contiamo solo la prima metà delle righe visibili
|
||||||
|
// per far avanzare l'applicazione non appena si arriva a metà della prima schermata
|
||||||
|
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
|
||||||
|
for (let i = 0; i < halfLength; i++) {
|
||||||
|
const idx = visibleIndices[i];
|
||||||
|
totalRawWords += this.getLineRawWordCount(idx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dalla seconda pagina in poi, contiamo solo le righe attive e future (escludendo il testo già cantato in alto)
|
||||||
|
for (const idx of visibleIndices) {
|
||||||
|
if (idx >= currentIdx) {
|
||||||
|
totalRawWords += this.getLineRawWordCount(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: calcolo teorico basato sulla pagina corrente
|
||||||
|
const pageSize = this.calculatePageStepSize();
|
||||||
|
const totalLines = this.getTotalLines();
|
||||||
|
|
||||||
|
let end: number;
|
||||||
|
if (currentIdx === 0) {
|
||||||
|
// Fallback prima pagina: metà delle righe della pagina teorica
|
||||||
|
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
|
||||||
|
end = Math.min(totalLines, currentIdx + halfPageSize);
|
||||||
|
} else {
|
||||||
|
end = Math.min(totalLines, currentIdx + pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = currentIdx; i < end; i++) {
|
||||||
|
totalRawWords += this.getLineRawWordCount(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soglia al 70% delle parole contate nella schermata visibile
|
||||||
|
return Math.max(1, Math.ceil(totalRawWords * 0.70));
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkWordAdvancement() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Cooldown per prevenire avanzamenti multipli troppo veloci
|
||||||
|
if (now - this.lastAdvanceTimestamp < this.ADVANCE_COOLDOWN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIdx = this.currentLineIndex();
|
||||||
|
let nextPageIdx: number;
|
||||||
|
const totalLines = this.getTotalLines();
|
||||||
|
|
||||||
|
if (currentIdx === 0) {
|
||||||
|
// Sulla prima pagina, la "pagina successiva" è in realtà la seconda metà della prima schermata
|
||||||
|
const visibleIndices = this.getVisibleLineIndices();
|
||||||
|
if (visibleIndices.length > 0) {
|
||||||
|
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
|
||||||
|
nextPageIdx = visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength;
|
||||||
|
} else {
|
||||||
|
const pageSize = this.calculatePageStepSize();
|
||||||
|
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
|
||||||
|
nextPageIdx = currentIdx + halfPageSize;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextPageIdx = this.calculateNextPageStartIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPageIdx >= totalLines) return;
|
||||||
|
|
||||||
|
const pageSize = this.calculatePageStepSize();
|
||||||
|
|
||||||
|
// Raccoglie le parole del blocco successivo (metà pagina se siamo all'inizio, altrimenti intera pagina)
|
||||||
|
const nextPageWords = this.getPageWords(nextPageIdx, currentIdx === 0 ? Math.max(1, Math.ceil(pageSize / 2)) : pageSize);
|
||||||
|
if (nextPageWords.length === 0) return;
|
||||||
|
|
||||||
|
// Parole rilevate di recente (con tolleranza di ritardo della Speech API)
|
||||||
|
const recentWords = this.audioEngine.getRecentWordsSince(this.lastAdvanceTimestamp);
|
||||||
|
|
||||||
|
// Controlla se l'utente ha cantato parole della pagina successiva -> Avanzamento istantaneo immediato
|
||||||
|
const matchNext = nextPageWords.some(word => recentWords.includes(word));
|
||||||
|
if (matchNext) {
|
||||||
|
console.log(`[SmartKaraoke] Parola chiave del blocco successivo rilevata! Avanzamento immediato a: ${nextPageIdx}`);
|
||||||
|
this.lastScrollBlock.set('center');
|
||||||
|
this.currentLineIndex.set(nextPageIdx);
|
||||||
|
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextPageIdx });
|
||||||
|
this.lastAdvanceTimestamp = now;
|
||||||
|
this.audioEngine.resetWordCount();
|
||||||
|
this.wordsSpokenInCurrentPage = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLineRawWordCount(lineIndex: number): number {
|
||||||
|
const allLines = this.getAllLines();
|
||||||
|
const lineText = allLines[lineIndex]?.text || '';
|
||||||
|
const words = lineText.trim().split(/\s+/).filter((w: string) => w.length > 0);
|
||||||
|
return words.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLineTargetWordCount(lineIndex: number): number {
|
||||||
|
const rawCount = this.getLineRawWordCount(lineIndex);
|
||||||
|
// Soglia proporzionale tollerante (65% del conteggio parole effettivo) per compensare legature melodiche
|
||||||
|
return Math.max(1, Math.ceil(rawCount * 0.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAcousticPeak() {
|
||||||
|
const targetWordCount = this.getPageTargetWordCount();
|
||||||
|
|
||||||
|
this.wordsSpokenInCurrentPage++;
|
||||||
|
console.log(`[PeakAdvance] Peak detected. Spoken words on page: ${this.wordsSpokenInCurrentPage}/${targetWordCount}`);
|
||||||
|
|
||||||
|
if (this.wordsSpokenInCurrentPage >= targetWordCount) {
|
||||||
|
console.log(`[PeakAdvance] Target page word count reached (${targetWordCount}). Advancing page.`);
|
||||||
|
this.next(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateNextPageStartIndex(): number {
|
||||||
|
try {
|
||||||
|
const container = document.querySelector('.lyrics-container') as HTMLElement;
|
||||||
|
if (!container) return this.currentLineIndex() + 1;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
let visibleBottom = containerRect.bottom;
|
||||||
|
|
||||||
|
// Trova la posizione del footer o di qualunque elemento sovrapposto in fondo
|
||||||
|
const footer = document.querySelector('ion-footer') as HTMLElement;
|
||||||
|
if (footer) {
|
||||||
|
const footerRect = footer.getBoundingClientRect();
|
||||||
|
if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') {
|
||||||
|
visibleBottom = Math.min(visibleBottom, footerRect.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sottrai un piccolo margine di tolleranza di 8px
|
||||||
|
const visibleLimitY = visibleBottom - 8;
|
||||||
|
const lineElems = document.querySelectorAll('.lyric-line');
|
||||||
|
|
||||||
|
const totalLines = this.getTotalLines();
|
||||||
|
const currentIdx = this.currentLineIndex();
|
||||||
|
|
||||||
|
// Scansiona le righe successive a quella attiva e trova la prima riga che non era COMPLETAMENTE visibile
|
||||||
|
let foundNextIdx = -1;
|
||||||
|
for (let i = currentIdx + 1; i < lineElems.length; i++) {
|
||||||
|
const el = lineElems[i] as HTMLElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Se la parte inferiore della riga ricade sotto l'area di visualizzazione utile (coperta da footer/log)
|
||||||
|
if (rect.bottom > visibleLimitY) {
|
||||||
|
foundNextIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundNextIdx !== -1) {
|
||||||
|
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx);
|
||||||
|
return foundNextIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard
|
||||||
|
const step = this.calculatePageStepSize();
|
||||||
|
return Math.min(totalLines - 1, currentIdx + step);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e);
|
||||||
|
return this.currentLineIndex() + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next(isAutomatic: boolean = false) {
|
||||||
|
this.lastAdvanceTimestamp = Date.now();
|
||||||
|
this.audioEngine.resetWordCount();
|
||||||
|
this.wordsSpokenInCurrentPage = 0;
|
||||||
|
|
||||||
|
const totalLines = this.getTotalLines();
|
||||||
|
const prevIdx = this.currentLineIndex();
|
||||||
|
|
||||||
|
if (prevIdx < totalLines - 1) {
|
||||||
|
let nextIdx: number;
|
||||||
|
if (isAutomatic) {
|
||||||
|
if (prevIdx === 0) {
|
||||||
|
// Sulla prima pagina, avanziamo alla metà della pagina (inizio della seconda metà)
|
||||||
|
const visibleIndices = this.getVisibleLineIndices();
|
||||||
|
if (visibleIndices.length > 0) {
|
||||||
|
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
|
||||||
|
nextIdx = Math.min(totalLines - 1, visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength);
|
||||||
|
} else {
|
||||||
|
const pageSize = this.calculatePageStepSize();
|
||||||
|
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
|
||||||
|
nextIdx = Math.min(totalLines - 1, prevIdx + halfPageSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Avanzamento acustico automatico: avanza l'intera pagina lasciando centrale la riga di riferimento
|
||||||
|
nextIdx = this.calculateNextPageStartIndex();
|
||||||
|
}
|
||||||
|
this.lastScrollBlock.set('center');
|
||||||
|
} else if (this.settingsService.karaokePageScrollMode()) {
|
||||||
|
// Modalità manuale a pagine: calcolo analitico preciso per non perdere righe coperte
|
||||||
|
nextIdx = this.calculateNextPageStartIndex();
|
||||||
|
this.lastScrollBlock.set('start');
|
||||||
|
} else {
|
||||||
|
// Avanzamento riga per riga standard manuale
|
||||||
|
nextIdx = prevIdx + 1;
|
||||||
|
this.lastScrollBlock.set('center');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentLineIndex.set(nextIdx);
|
||||||
|
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti
|
||||||
|
|
||||||
prev() {
|
prev() {
|
||||||
this.lastAdvanceTimestamp = Date.now();
|
this.lastAdvanceTimestamp = Date.now();
|
||||||
this.audioEngine.resetWordCount();
|
this.audioEngine.resetWordCount();
|
||||||
if (this.currentLineIndex() > 0) {
|
this.wordsSpokenInCurrentPage = 0;
|
||||||
this.currentLineIndex.set(this.currentLineIndex() - 1);
|
|
||||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: this.currentLineIndex() });
|
const prevIdx = this.currentLineIndex();
|
||||||
|
if (prevIdx > 0) {
|
||||||
|
const isPageMode = this.settingsService.karaokePageScrollMode();
|
||||||
|
const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
|
||||||
|
const nextIdx = Math.max(0, prevIdx - stepSize);
|
||||||
|
|
||||||
|
if (nextIdx === 0) {
|
||||||
|
// Se torniamo all'inizio, allineiamo in alto
|
||||||
|
this.lastScrollBlock.set('start');
|
||||||
|
} else if (isPageMode) {
|
||||||
|
this.lastScrollBlock.set('start');
|
||||||
|
} else {
|
||||||
|
this.lastScrollBlock.set('center');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentLineIndex.set(nextIdx);
|
||||||
|
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextIdx });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,14 @@
|
|||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle>
|
<ion-toggle slot="end" [checked]="settingsService.enableAcousticAutoscroll()" (ionChange)="settingsService.toggleAcousticAutoscroll()" color="secondary"></ion-toggle>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||||
|
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Avanzamento manuale a pagine</h2>
|
||||||
|
<p class="settings-item-subtitle">I tasti Avanti/Indietro (Karaoke) voltano la pagina intera anziché riga per riga</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.karaokePageScrollMode()" (ionChange)="settingsService.toggleKaraokePageScrollMode()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comunità -->
|
<!-- Comunità -->
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ export class SettingsPage {
|
|||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
private async forceBypassCacheAndCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// 1. Forza il browser mobile a controllare la rete per aggiornamenti al Service Worker nativo
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
for (const registration of registrations) {
|
||||||
|
await registration.update();
|
||||||
|
console.log('[PWA-Update] Native Service Worker updated from settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Forza il caricamento di ngsw.json bypassando le cache intermedie e locali
|
||||||
|
await fetch(`/ngsw.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||||
|
await fetch('/ngsw.json', { cache: 'reload' });
|
||||||
|
console.log('[PWA-Update] Caches successfully busted for ngsw.json');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PWA-Update] Failed to bust cache for ngsw.json:', e);
|
||||||
|
}
|
||||||
|
return await this.swUpdate.checkForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
async installApp() {
|
async installApp() {
|
||||||
await this.settingsService.installPwa();
|
await this.settingsService.installPwa();
|
||||||
}
|
}
|
||||||
@@ -83,7 +103,7 @@ export class SettingsPage {
|
|||||||
// 4. Check for Service Worker updates
|
// 4. Check for Service Worker updates
|
||||||
if (this.swUpdate.isEnabled) {
|
if (this.swUpdate.isEnabled) {
|
||||||
try {
|
try {
|
||||||
const updateFound = await this.swUpdate.checkForUpdate();
|
const updateFound = await this.forceBypassCacheAndCheck();
|
||||||
if (updateFound) {
|
if (updateFound) {
|
||||||
const toast = await this.toastCtrl.create({
|
const toast = await this.toastCtrl.create({
|
||||||
message: 'Nuova versione disponibile! Aggiornamento in corso...',
|
message: 'Nuova versione disponibile! Aggiornamento in corso...',
|
||||||
@@ -143,7 +163,7 @@ export class SettingsPage {
|
|||||||
await toastLoading.present();
|
await toastLoading.present();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updateFound = await this.swUpdate.checkForUpdate();
|
const updateFound = await this.forceBypassCacheAndCheck();
|
||||||
if (updateFound) {
|
if (updateFound) {
|
||||||
const toast = await this.toastCtrl.create({
|
const toast = await this.toastCtrl.create({
|
||||||
message: 'Nuova versione trovata! Installazione e attivazione in corso...',
|
message: 'Nuova versione trovata! Installazione e attivazione in corso...',
|
||||||
|
|||||||
@@ -11,12 +11,21 @@ export class AudioEngineService {
|
|||||||
public searchTranscript = signal<string>('');
|
public searchTranscript = signal<string>('');
|
||||||
public isSearching = signal<boolean>(false);
|
public isSearching = signal<boolean>(false);
|
||||||
public sensitivity = signal<number>(75); // Default sensitivity (50-100)
|
public sensitivity = signal<number>(75); // Default sensitivity (50-100)
|
||||||
|
public speechRecognitionActive = signal<boolean>(false);
|
||||||
|
|
||||||
|
// Real-time background continuous transcript and word buffer for reliability checks
|
||||||
|
public backgroundTranscript = signal<string>('');
|
||||||
|
private backgroundRecognition: any = null;
|
||||||
|
private recentWordsBuffer: { word: string, timestamp: number }[] = [];
|
||||||
|
|
||||||
public clearSearchTranscript() {
|
public clearSearchTranscript() {
|
||||||
this.searchTranscript.set('');
|
this.searchTranscript.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
private audioContext: AudioContext | null = null;
|
private audioContext: AudioContext | null = null;
|
||||||
|
private highpassFilter: BiquadFilterNode | null = null;
|
||||||
|
private lowpassFilter: BiquadFilterNode | null = null;
|
||||||
|
private vocalBandpass: BiquadFilterNode | null = null;
|
||||||
private analyser: AnalyserNode | null = null;
|
private analyser: AnalyserNode | null = null;
|
||||||
private stream: MediaStream | null = null;
|
private stream: MediaStream | null = null;
|
||||||
private animationFrame: number | null = null;
|
private animationFrame: number | null = null;
|
||||||
@@ -33,9 +42,118 @@ export class AudioEngineService {
|
|||||||
private readonly COOLDOWN = 1000; // ms
|
private readonly COOLDOWN = 1000; // ms
|
||||||
private peakEnergy: number = 0;
|
private peakEnergy: number = 0;
|
||||||
private lineStarted: boolean = false;
|
private lineStarted: boolean = false;
|
||||||
|
private noiseFloor: number = 30; // Adaptive noise floor starting point
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initSpeechRecognition();
|
this.initSpeechRecognition();
|
||||||
|
this.initBackgroundRecognition();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initBackgroundRecognition() {
|
||||||
|
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
if (SpeechRecognition) {
|
||||||
|
this.backgroundRecognition = new SpeechRecognition();
|
||||||
|
this.backgroundRecognition.continuous = true;
|
||||||
|
this.backgroundRecognition.interimResults = true;
|
||||||
|
this.backgroundRecognition.lang = 'it-IT';
|
||||||
|
|
||||||
|
this.backgroundRecognition.onstart = () => {
|
||||||
|
console.log('Background speech recognition started successfully');
|
||||||
|
this.speechRecognitionActive.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.backgroundRecognition.onresult = (event: any) => {
|
||||||
|
let interimTranscript = '';
|
||||||
|
let finalTranscript = '';
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
finalTranscript += event.results[i][0].transcript;
|
||||||
|
} else {
|
||||||
|
interimTranscript += event.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fullTranscript = (finalTranscript + ' ' + interimTranscript).trim().toLowerCase();
|
||||||
|
if (fullTranscript) {
|
||||||
|
this.backgroundTranscript.set(fullTranscript);
|
||||||
|
this.updateRecentWords(fullTranscript);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.backgroundRecognition.onerror = (err: any) => {
|
||||||
|
console.error('Background recognition error:', err);
|
||||||
|
this.speechRecognitionActive.set(false);
|
||||||
|
if (this.isListening() && err.error !== 'aborted' && err.error !== 'not-allowed') {
|
||||||
|
setTimeout(() => this.startBackgroundRecognition(), 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.backgroundRecognition.onend = () => {
|
||||||
|
console.log('Background speech recognition ended');
|
||||||
|
if (this.isListening()) {
|
||||||
|
this.startBackgroundRecognition();
|
||||||
|
} else {
|
||||||
|
this.speechRecognitionActive.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBackgroundRecognition() {
|
||||||
|
if (!this.backgroundRecognition) return;
|
||||||
|
try {
|
||||||
|
this.backgroundRecognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
// Recognition already started or running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopBackgroundRecognition() {
|
||||||
|
if (!this.backgroundRecognition) return;
|
||||||
|
try {
|
||||||
|
this.backgroundRecognition.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Recognition already stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRecentWords(transcript: string) {
|
||||||
|
const words = transcript.split(/\s+/);
|
||||||
|
const now = Date.now();
|
||||||
|
words.forEach(w => {
|
||||||
|
// Strip special characters and keep alphanumeric Italian letters
|
||||||
|
const cleaned = w.replace(/[^a-zA-Z0-9àèìòùáéíóú]/g, '').toLowerCase();
|
||||||
|
if (cleaned.length >= 3) {
|
||||||
|
// Avoid adding duplicate words within the last 3 seconds
|
||||||
|
const existing = this.recentWordsBuffer.find(item => item.word === cleaned && now - item.timestamp < 3000);
|
||||||
|
if (!existing) {
|
||||||
|
this.recentWordsBuffer.push({ word: cleaned, timestamp: now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only words from the last 10 seconds to make it highly responsive
|
||||||
|
this.recentWordsBuffer = this.recentWordsBuffer.filter(item => now - item.timestamp < 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRecentWords(): string[] {
|
||||||
|
const now = Date.now();
|
||||||
|
this.recentWordsBuffer = this.recentWordsBuffer.filter(item => now - item.timestamp < 10000);
|
||||||
|
return this.recentWordsBuffer.map(item => item.word);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRecentWordsSince(timestamp: number): string[] {
|
||||||
|
const now = Date.now();
|
||||||
|
// Consente una sovrapposizione di 1.5 secondi per coprire la latenza di SpeechRecognition
|
||||||
|
const cutoff = Math.max(timestamp - 1500, now - 10000);
|
||||||
|
this.recentWordsBuffer = this.recentWordsBuffer.filter(item => now - item.timestamp < 10000);
|
||||||
|
return this.recentWordsBuffer
|
||||||
|
.filter(item => item.timestamp > cutoff)
|
||||||
|
.map(item => item.word);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearRecentWords() {
|
||||||
|
this.recentWordsBuffer = [];
|
||||||
|
this.backgroundTranscript.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
private initSpeechRecognition() {
|
private initSpeechRecognition() {
|
||||||
@@ -89,16 +207,38 @@ export class AudioEngineService {
|
|||||||
audio: {
|
audio: {
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
autoGainControl: false // Prevents boosting background noise during silence
|
autoGainControl: false // Impedisce il boost automatico del rumore di fondo nel silenzio
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = new AudioContext();
|
||||||
const source = this.audioContext.createMediaStreamSource(this.stream);
|
const source = this.audioContext.createMediaStreamSource(this.stream);
|
||||||
|
|
||||||
|
// Catena di filtri in cascata per isolamento vocale
|
||||||
|
this.highpassFilter = this.audioContext.createBiquadFilter();
|
||||||
|
this.highpassFilter.type = 'highpass';
|
||||||
|
this.highpassFilter.frequency.value = 150; // Taglia i bassi e rombi di fondo
|
||||||
|
|
||||||
|
this.lowpassFilter = this.audioContext.createBiquadFilter();
|
||||||
|
this.lowpassFilter.type = 'lowpass';
|
||||||
|
this.lowpassFilter.frequency.value = 2000; // Taglia sibili e armonici acuti di chitarre
|
||||||
|
|
||||||
|
this.vocalBandpass = this.audioContext.createBiquadFilter();
|
||||||
|
this.vocalBandpass.type = 'bandpass';
|
||||||
|
this.vocalBandpass.frequency.value = 800; // Centro della gamma vocale primaria
|
||||||
|
this.vocalBandpass.Q.value = 1.2; // Filtro stretto focalizzato sulla voce
|
||||||
|
|
||||||
this.analyser = this.audioContext.createAnalyser();
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 512;
|
this.analyser.fftSize = 512;
|
||||||
|
|
||||||
source.connect(this.analyser);
|
// Connessione in cascata: source -> highpass -> lowpass -> bandpass -> analyser
|
||||||
|
source.connect(this.highpassFilter);
|
||||||
|
this.highpassFilter.connect(this.lowpassFilter);
|
||||||
|
this.lowpassFilter.connect(this.vocalBandpass);
|
||||||
|
this.vocalBandpass.connect(this.analyser);
|
||||||
|
|
||||||
this.isListening.set(true);
|
this.isListening.set(true);
|
||||||
|
this.clearRecentWords();
|
||||||
|
this.startBackgroundRecognition();
|
||||||
this.processAudio();
|
this.processAudio();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error accessing microphone', err);
|
console.error('Error accessing microphone', err);
|
||||||
@@ -112,6 +252,12 @@ export class AudioEngineService {
|
|||||||
this.audioContext?.close();
|
this.audioContext?.close();
|
||||||
this.isListening.set(false);
|
this.isListening.set(false);
|
||||||
this.energyLevel.set(0);
|
this.energyLevel.set(0);
|
||||||
|
this.stopBackgroundRecognition();
|
||||||
|
this.clearRecentWords();
|
||||||
|
this.highpassFilter = null;
|
||||||
|
this.lowpassFilter = null;
|
||||||
|
this.vocalBandpass = null;
|
||||||
|
this.speechRecognitionActive.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private processAudio() {
|
private processAudio() {
|
||||||
@@ -120,20 +266,37 @@ export class AudioEngineService {
|
|||||||
const bufferLength = this.analyser.frequencyBinCount;
|
const bufferLength = this.analyser.frequencyBinCount;
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
const analyze = () => {
|
// Con la catena di filtri in cascata, l'energia vocale è centrata tra 200 Hz e 2000 Hz.
|
||||||
this.analyser!.getByteFrequencyData(dataArray);
|
const sampleRate = this.audioContext ? this.audioContext.sampleRate : 44100;
|
||||||
|
const binWidth = sampleRate / 512;
|
||||||
|
const startBin = Math.max(0, Math.floor(200 / binWidth));
|
||||||
|
const endBin = Math.min(bufferLength - 1, Math.floor(2000 / binWidth));
|
||||||
|
|
||||||
// Calculate average energy (volume)
|
const analyze = () => {
|
||||||
|
if (!this.analyser) return;
|
||||||
|
this.analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
// Calcola l'energia media ESCLUSIVAMENTE nella gamma di frequenza della voce
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
for (let i = startBin; i <= endBin; i++) {
|
||||||
sum += dataArray[i];
|
sum += dataArray[i];
|
||||||
}
|
}
|
||||||
const avgEnergy = sum / bufferLength;
|
const avgEnergy = sum / (endBin - startBin + 1);
|
||||||
this.energyLevel.set(avgEnergy);
|
this.energyLevel.set(avgEnergy);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Balanced mapping: 0% -> 220 (very quiet), 100% -> 20 (very sensitive)
|
|
||||||
const currentThreshold = 220 - (this.sensitivity() * 2.0);
|
// Tracciamento adattivo del rumore di fondo
|
||||||
|
if (avgEnergy < this.noiseFloor) {
|
||||||
|
this.noiseFloor = this.noiseFloor * 0.95 + avgEnergy * 0.05;
|
||||||
|
} else {
|
||||||
|
this.noiseFloor = this.noiseFloor * 0.998 + avgEnergy * 0.002;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soglia di sensibilità definita dall'utente
|
||||||
|
const userThreshold = 220 - (this.sensitivity() * 2.0);
|
||||||
|
// La soglia effettiva si adatta al rumore di fondo per evitare falsi avanzamenti
|
||||||
|
const currentThreshold = Math.max(userThreshold, this.noiseFloor + 12);
|
||||||
|
|
||||||
if (avgEnergy > currentThreshold) {
|
if (avgEnergy > currentThreshold) {
|
||||||
if (avgEnergy > this.peakEnergy) {
|
if (avgEnergy > this.peakEnergy) {
|
||||||
@@ -146,23 +309,22 @@ export class AudioEngineService {
|
|||||||
this.lastSilenceTime = now;
|
this.lastSilenceTime = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se siamo in "speaking" e sentiamo un calo significativo rispetto al picco recente (almeno 30% di calo)
|
// Rilevamento della caduta di energia relativa per identificare stacchi tra sillabe/parole (legato acustico)
|
||||||
// Questo permette di avanzare anche se c'è rumore di fondo sopra la soglia base.
|
|
||||||
const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy;
|
const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy;
|
||||||
if (this.isSpeaking && dropRatio > 0.35 && this.peakEnergy > currentThreshold * 1.2) {
|
if (this.isSpeaking && dropRatio > 0.20 && this.peakEnergy > currentThreshold * 1.1) {
|
||||||
if (now - this.lastWordTime > this.COOLDOWN) {
|
if (now - this.lastWordTime > this.COOLDOWN) {
|
||||||
this.linesDetected.update(v => v + 1);
|
this.linesDetected.update(v => v + 1);
|
||||||
this.lastWordTime = now;
|
this.lastWordTime = now;
|
||||||
this.peakEnergy = avgEnergy; // Reset peak
|
this.peakEnergy = avgEnergy; // Reset peak
|
||||||
this.isSpeaking = false;
|
this.isSpeaking = false;
|
||||||
console.log('Line advanced - relative drop detected', { dropRatio, avgEnergy, peak: this.peakEnergy });
|
console.log('Line advanced - relative drop detected (highly sensitive)', { dropRatio, avgEnergy, peak: this.peakEnergy });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastSilenceTime = now;
|
this.lastSilenceTime = now;
|
||||||
} else {
|
} else {
|
||||||
// Sotto soglia (silenzio per il sistema)
|
// Sotto soglia (silenzio)
|
||||||
if (this.isSpeaking && (now - this.lastSilenceTime > 200)) {
|
if (this.isSpeaking && (now - this.lastSilenceTime > 180)) {
|
||||||
if (now - this.lastWordTime > this.COOLDOWN) {
|
if (now - this.lastWordTime > this.COOLDOWN) {
|
||||||
this.linesDetected.update(v => v + 1);
|
this.linesDetected.update(v => v + 1);
|
||||||
this.lastWordTime = now;
|
this.lastWordTime = now;
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class SettingsService {
|
|||||||
/** Preferenza notazione accordi: diesis o bemolle */
|
/** Preferenza notazione accordi: diesis o bemolle */
|
||||||
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
||||||
|
|
||||||
|
/** Avanzamento a pagine del karaoke manuale: true = i tasti next/prev voltano la pagina */
|
||||||
|
public karaokePageScrollMode = signal<boolean>(false);
|
||||||
|
|
||||||
private wakeLock: any = null;
|
private wakeLock: any = null;
|
||||||
|
|
||||||
// PWA installation signals
|
// PWA installation signals
|
||||||
@@ -198,6 +201,13 @@ export class SettingsService {
|
|||||||
this.chordNotationPreference.set('diesis');
|
this.chordNotationPreference.set('diesis');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedKaraokePageScrollMode = localStorage.getItem('karaoke-page-scroll-mode');
|
||||||
|
if (savedKaraokePageScrollMode !== null) {
|
||||||
|
this.karaokePageScrollMode.set(savedKaraokePageScrollMode === 'true');
|
||||||
|
} else {
|
||||||
|
this.karaokePageScrollMode.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||||
const updateFullscreenState = () => {
|
const updateFullscreenState = () => {
|
||||||
const isFs = !!(
|
const isFs = !!(
|
||||||
@@ -270,6 +280,10 @@ export class SettingsService {
|
|||||||
localStorage.setItem('chord-notation-preference', this.chordNotationPreference());
|
localStorage.setItem('chord-notation-preference', this.chordNotationPreference());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
localStorage.setItem('karaoke-page-scroll-mode', this.karaokePageScrollMode().toString());
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const active = this.keepScreenOn();
|
const active = this.keepScreenOn();
|
||||||
localStorage.setItem('keep-screen-on', active.toString());
|
localStorage.setItem('keep-screen-on', active.toString());
|
||||||
@@ -396,6 +410,12 @@ export class SettingsService {
|
|||||||
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
|
localStorage.setItem('enable-acoustic-autoscroll', newValue.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleKaraokePageScrollMode() {
|
||||||
|
const newValue = !this.karaokePageScrollMode();
|
||||||
|
this.karaokePageScrollMode.set(newValue);
|
||||||
|
localStorage.setItem('karaoke-page-scroll-mode', newValue.toString());
|
||||||
|
}
|
||||||
|
|
||||||
setChordNotationPreference(val: 'diesis' | 'bemolle') {
|
setChordNotationPreference(val: 'diesis' | 'bemolle') {
|
||||||
this.chordNotationPreference.set(val);
|
this.chordNotationPreference.set(val);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = '2026.05.23.1602';
|
export const VERSION = '2026.06.04.1906';
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
<base href="/"/>
|
<base href="/"/>
|
||||||
|
|
||||||
|
<!-- SEO & OpenGraph Meta Tags -->
|
||||||
|
<meta name="description" content="Testi e accordi di canti cristiani e liturgici, sempre con te anche offline." />
|
||||||
|
<meta property="og:title" content="Canti Cristiani" />
|
||||||
|
<meta property="og:description" content="Testi e accordi di canti cristiani e liturgici, sempre con te anche offline." />
|
||||||
|
<meta property="og:image" content="assets/icon/favicon.png" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://www.canticristiani.it/" />
|
||||||
|
|
||||||
<meta name="color-scheme" content="light dark"/>
|
<meta name="color-scheme" content="light dark"/>
|
||||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
|
||||||
<meta name="format-detection" content="telephone=no"/>
|
<meta name="format-detection" content="telephone=no"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user